halftone

halftone

Fill from image tone — decode an sRGB photo to RGBA yourself, then pick one exclusive mark mode: angled lines or lattice circles.

The only product output is a pen-plotter SVG: open/closed stroked paths via toSvg (fill="none" on every path). PNGs in examples are optional previews of that SVG, not a substitute.

Imports

import {
  fromRgba, fromLuminance, halftoneLines, halftoneCircles, toSvg,
  type ToneField, type HalftoneLinesOptions, type HalftoneCirclesOptions,
} from 'pattapatta'
// or from 'pattapatta/halftone'

Pipeline

  1. Decode JPEG/PNG → RGBA buffer (e.g. sharp, browser canvas). Not included in the library.
  2. fromRgba(data, width, height) → ToneField (darkness in [0,1]).
  3. Either halftoneLines(field, opts) or halftoneCircles(field, opts) — not both in one pass.
  4. toSvg(marks) → stroke-only SVG for the plotter (AxiDraw, etc.).
import sharp from 'sharp'
import { fromRgba, halftoneCircles, toSvg, reducePrecision, group } from 'pattapatta'

const { data, info } = await sharp('photo.jpg')
  .rotate()
  .resize({ width: 800 })
  .ensureAlpha()
  .raw()
  .toBuffer({ resolveWithObject: true })

const field = fromRgba(data, info.width, info.height)
const marks = halftoneCircles(field, {
  cell: 3.5,
  lattice: 'hex',
  minRadius: 0.2,
  maxRadius: 1.5,
  segments: 12, // fewer verts = lighter plotter SVG
})
// optional: shrink file size for machine drivers
const plot = group(marks.paths.map((p) => reducePrecision(p, 2)))
writeFileSync('out.svg', toSvg(plot, { strokeWidth: 0.25 }))

fromRgba(data, width, height, options?) → ToneField

Packed RGBA (4 bytes/pixel). Rec.709 luma on sRGB channels; default maps dark pixels to high darkness (more ink).

OptionDefaultMeaning
invertfalseLight pixels become high darkness

halftoneLines(field, options?) → Group

Angled scanlines; contiguous dark runs become open polylines. Multi-levels overlay extra thresholds so darker regions get denser ink.

OptionDefaultMeaning
angleπ/4Line direction (radians)
spacing2Perpendicular pitch
step1Sample step along each line
levels1Threshold overlays
minDarkness0Ignore lighter samples
scale1Image unit → SVG unit

halftoneCircles(field, options?) → Group

Square or hex lattice; circle radius scales with darkness:

r = maxRadius * darkness^gamma (skip if r < minRadius)

Circles are stroked n-gons (createCircle), not filled disks. With overlap: true, lattice pitch is derived so max-radius neighbors share overlapAmount of a diameter — stroke crossings create denser midtones (single pen).

OptionDefaultMeaning
cell4Lattice pitch (ignored when overlap is true)
lattice'square''square' | 'hex'
minRadius0.3Skip sites mapping below this radius
maxRadiuscell * 0.45 (or 8 if overlapping)Radius at full darkness
overlapfalseDerive pitch so max-radius neighbors overlap
overlapAmount0.35Shared fraction of max diameter when overlap
gamma1Tone response curve
segments24N-gon resolution
scale1Image unit → SVG unit
// Non-overlapping lattice (default) — use a small `cell` for photo detail
halftoneCircles(field, { cell: 3.5, lattice: 'hex', minRadius: 0.15, maxRadius: 1.55 })

// Overlapping: pitch from maxRadius + overlapAmount
halftoneCircles(field, {
  overlap: true,
  overlapAmount: 0.32,
  minRadius: 0.25,
  maxRadius: 2.4,
  lattice: 'hex',
})

Modes are exclusive

Call either lines or circles for a given conversion. Later patterns (spirals, squiggle, …) will be additional exclusive modes on the same ToneField.

Photo credit

Example fixture and docs previews use Buzz Aldrin walking on the moon by History in HD on Unsplash.