Most halftone tools are filters: pixels in, dots out, and whatever the printer
makes of it afterwards is the printer’s problem. halftoner starts from the other
end. A press profile — the paper, its dot gain, the smallest dot it can hold,
the finest screen it can carry, how much ink it will take before it stops
drying — is the input, and the picture is what gets fitted to it.
So the artifact is a recipe, not an image. It carries the canvas, the ink set,
the overprints, the sources and the seed, and every output is a render of it:
a screen proof, an SVG, print-on-demand sizes, 1-bit film positives with
registration marks and a step wedge, or PDF/X-1a with one overprinting
separation per ink and the dots already screened so the RIP leaves them alone.
Compositing happens in ink space through the Neugebauer primaries rather than
as RGB layers with multiply, which is the whole reason an overprint can be a
colour you picked instead of one arithmetic handed you.
One ramp, four cell fills. A screen is a fill function over a
grid — round dots join their neighbours all at once, elliptical ones join
along the long axis first, and a line screen never joins across at all.
Every constraint is computed for every job and each target decides what a
failure means — the screen target reports a ruling past the paper’s ceiling,
print-on-demand caps it, film and PDF refuse and say why. Press artifacts are
modelled rather than faked: misregistration with a low-frequency walk, slur
along the direction of travel, ink film thickness wandering across the sheet,
all seeded and all applied per ink, never per RGB channel. There is an
underbase generator for dark garments, because white on cotton spreads far
more than the colours printed onto it and wants its own curve.
The part I care about most is the loop back to reality. halftoner measure
reads a scan of real print — the paper, the inks and their overprints, the
ruling and angle of each screen, the registration error between plates, the
gain — and writes a draft profile from it. Print the step wedge that comes on
a film sheet, scan it, feed it back, and the numbers stop being generic. The
profiles that ship are all marked NOMINAL for exactly this reason: they are
plausible ranges, not anybody’s actual press, and the provenance field says so
until a scan replaces it.
Readme
Ink-first halftones from press profiles. A screen is a fill function over a grid; a job is an ink set; a recipe is the artifact and every output is a render of it.
uv sync
uv run pytest
uv run halftoner profiles
uv run halftoner new job.json --profile newsprint_nominal --image photo.jpg --size 180x240 --dpi 150 \
--ink warm_red=#D6422B --ink prussian=#1F3A63 --overprint warm_red+prussian=#3A2036 --seed 7
uv run halftoner report job.json --target film # report + what the film target would do
uv run halftoner render job.json --target screen # screen | svg | pod | film | pdf [--force]
uv run halftoner render job.json --target pod --size all # a file per standard size band
uv run halftoner new cmyk.json --profile uncoated_offset_nominal --image photo.jpg --size 180x240 --process
uv run halftoner new grid.json --profile newsprint_nominal --image photo.jpg --size 180x240 --ink k=#111 --grid 20
uv run python examples/two_ink_poster.py [photo.jpg] # the Python surface
uv run halftoner measure scan.tif --inks 2 --dpi 1200 --item "title, publisher, year" \
--wedge 12,200,6 --patch-ink ink2 --profile-out draft.json # scan -> report + DRAFT profile
Looks
Six looks off three photographs. Same canvas, same seed, same 4x supersample — all that
changes between them is the profile, the ink set and the screen.
Newsprint, one ink. 65 lpi elliptical on a cream stock that spreads 28% at the midtone, compensated back on the plate.
The screen as the subject. The same face at 16 lpi. Nothing changed but the ruling.
Two spot inks. Midnight and rust on their own curves, overprinting a colour that was chosen — #0F1622 — not computed.
CMYK process. A device separation: complementary CMY from sRGB, 70% grey component replacement, a 300% ink limit, KCMY at 45/15/75/0°.
Line screen. The same photograph carried by line weight alone. The fill never joins across, so tone is all that varies.
On a near-black shirt. Both inks are lighter than the garment, so tone_range auto reads the picture as range: more ink where the image is lighter, and the dark shirt prints as bare cotton.
Regenerate them with your own photographs:
uv run python examples/styles.py --portrait face.jpg --night dusk.jpg --colour orchard.jpg
Anything you leave out falls back to a synthetic stand-in, except --colour, whose panel is
a four-ink separation and is skipped instead — a stand-in there would be demonstrating the
stand-in rather than the separation. --graphic takes flat art for the line screen if you
have some, and falls back to --colour if you don't.
Pipeline
PressProfile ── substrate · screen (ruling, shape, angle set) · press · tone model · ink defaults · provenance
│ builds
▼
Recipe (saved as JSON: canvas, inks, overprints, sources, all params, seed)
│
Source (image | constant | gradient | function, optionally Masked / Layered)
│ sampled at each cell center, box-prefiltered to cell size
▼
Transfer tone → area (Yule–Nielsen between the base and the ink's solid as printed) → coverage bias → ink curve → compression
→ inverse substrate gain → highlight drop / shadow snap = plate area
▼
Plate one per ink: rotated grid, per-cell area; printed = gain(plate) + press gain
▼
Report sampling ratio, dot range, min dot diameter, coverage, palette, checks (always computed)
▼
Policy per target: report | warn | cap | refuse (forceable)
▼
Targets screen supersampled, press artifacts, ink bitmask → Neugebauer primaries → linear box average → sRGB
pod screen at each size band, ruling capped, RGBA with hard alpha
svg one <path> per ink in mm; arcs below the join, traced polygons above
film per ink, 1-bit, no AA, no artifacts, mirrored; crop marks, reg targets, label, step wedge
pdf PDF/X-1a:2001: one overprinting spot separation per ink, pre-screened vector dots,
trim/bleed boxes, registration-color marks, registered output intent
Press profiles
src/halftoner/profiles/*.json. One file holds everything a press implies:
gain is a number (TVI at 50%) or measured [[nominal, printed], ...] pairs. Inks take angles from the profile's angle set in print order. Anything can be overridden per recipe: profile.recipe(..., press={"misregistration": 0.5}).
The bundled profiles are labeled NOMINAL. A profile named for a tradition should come from measured scans (see _template_measured.json.example): ruling counted against trim size, angles read off a rotated crop, overprints sampled, plate walk measured. The film target's step wedge closes the gain loop: print it, measure the patches, feed the pairs back as gain.
Grid-locked screens
Screen(grid=ModuleGrid(repeat_mm, repeat_y_mm=None, origin=(x, y))) (or new --grid 20) locks every plate to a layout grid's repeat (module plus gutter). A square lattice turned to an angle with tan = q/p repeats every pitch x sqrt(p² + q²) along both page axes, so each ink's angle snaps to the nearest such rational angle (0°, 14.04°, 18.43°, 26.57°, 45°, …, with p, q ≤ max_ratio) and its ruling snaps so a whole number of those periods spans the repeat. Every module then carries an identical dot arrangement, and the screen composes with the grid rather than sitting on top of it. The requested quadrant is kept, so elliptical dots stay oriented. The report lists each ink's target and locked ruling and angle, and flags a vertical repeat that isn't a whole number of periods. Capping keeps rulings locked and under the ceiling.
Print on demand
The recipe is the artifact; size is an input to a run. Recipe.at_size(w, h, dpi) renders the same design at another size: art geometry scales (regions, gradients, image boxes, function coordinates, screen origin, layout grid) while press physics doesn't (ruling in lpi, misregistration, slur, trap gap, choke, bleed), so a bigger print carries more dots, not bigger ones.
--target pod --size NAME|all matches the design's aspect to standard print sizes (pod_bands.json: 2:3, 3:4, 4:5, 5:7, 1:1, 11:14, in either orientation) and writes a file per band, each rendered at its size and resolution with its ruling capped coarse (max_lpi, default dpi / 5, so every cell spans five pixels). --bands file.json supplies your own.
PoD files are RGBA with hard alpha: nothing is painted where no ink prints, inks are composited over white so the stock isn't baked in, and alpha is fully on or off (the inked fraction thresholded at half), which prints cleanly on transfers and apparel. --alpha soft keeps the fraction; --alpha none paints the substrate.
CMYK process
process_inks(photo, CmykSeparation(gcr, black_start, tac)) (or new --process) separates a photo into black, cyan, magenta and yellow plates, printed KCMY at 45/15/75/0°. It's a device separation: complementary CMY from sRGB, gray component replacement (black takes gcr of the shared gray once it passes black_start), undercolor removal, and a total-ink limit that holds black and scales CMY. For a separation matched to a printing condition, separate in an ICC workflow and feed the channels in as area sources.
Substrates carry tac (uncoated 300%, newsprint 240%); the report sums every plate at common points and film and PDF refuse past it. In PDF/X, process inks are DeviceCMYK (1 0 0 0 k with overprint mode 1, so the plates don't knock each other out) and any spot inks alongside stay Separations.
Underbase (dark garments)
Recipe(underbase=Underbase(...)), profile.recipe(..., underbase=True), or halftoner new --underbase adds a plate printed first, derived from where the colors print:
Demand: at each underbase cell, the heaviest printed coverage of any color, each through its own tone chain, optionally weighted per ink (weights={"navy": 0.5} for less base under dark inks).
Choke (choke_mm): tonal edges are eroded on the underbase's cell grid (minimum over a disk); region edges are cut at full resolution with every color region inset by the choke, so misregistration doesn't show a halo. The tonal erosion is quantised to whole cells, so a choke finer than one cell doesn't move a tonal edge at all — the report says so rather than letting it pass silently. plastisol_dark_garment_nominal's 0.3 mm is 0.53 of a cell at its own 45 lpi, so on a photographic source only its region edges are choked; raise the choke or the ruling if you need the tonal ones too.
Own gain (gain): compensated on its own curve, since white on cotton spreads far more than the colors on the flashed base.
It prints first, so it's the key plate for misregistration, joins the palette (underbase + gold), the angle checks, ruling caps, film, and PDF separations.
The garment is the substrate's paper color. Inks with opacity cover with their own color, so plastisol over the base reads true while ink straight on the shirt sinks. plastisol_dark_garment_nominal is a starting profile (NOMINAL, not measured).
Tone relative to the base. An ink moves reflectance between the bare base and its own solid as printed (with its opacity, over any underbase); the report prints both for every ink. Transfer(tone_range=...):
paper: image white is the base; a dark ink darkens it, and tones darker than its solid clip. Ink on paper.
range: image white and black map to the lighter and darker of base and solid. The only reading that works for a light ink on a dark garment (more ink where the image is lighter), and a way to spread a light ink across a whole photo on paper.
auto (default): range when the solid is lighter than the base, otherwise paper, which is identical to the classic paper-relative mapping.
Press artifacts
All seeded, all per ink (never per RGB channel), applied by the screen and pod targets and left off film.
Artifact
Parameter
Model
Misregistration
misregistration, drift, key
per-plate constant offset plus low-frequency walk; the key plate stays put. Region cuts move with their plate
Slur
slur, press_angle
each dot smeared along the press direction
Density variance
density_variance
ink film thickness wanders across the sheet (±fraction, low frequency); each present ink's density scales Beer–Lambert style on top of its primary, so chosen overprints keep their character
Trap gap
trap_gap
masked regions are choked by half the gap, opening a paper hairline where regions meet. With misregistration, butted regions already gap on one side and overlap on the other
Uncorrected gain
extra_gain
an anti-curve applied on press
Masked regions are cut at full resolution, the way a tint was cut from film: edge cells carry the region's value and their dots are sliced at the true edge (raster, film, and SVG clip paths).
Measuring scans
halftoner measure turns a scan of real print into a report and a draft profile. Scan at 10+ pixels per screen cell: 1200 dpi covers screens up to 120 lpi; use 2400 dpi for finer work. Below that, blurred dot edges swamp the pure-ink pixels the separation relies on, and the report will say so.
Reading
How
Paper
median of the lightest 2% of pixels, or --paper-box
Inks and overprints
pixels clustered in optical-density space into 2ⁿ primaries, each walked to its density peak; single inks are the subset whose sums best explain the rest. Overprints that aren't subtractive are flagged (chosen color, opaque ink, trap)
Ruling and angle
Fourier peak of each ink's unmixed amount map, pooled over a 3×3 grid of windows
Misregistration
each plate descreened and phase-correlated against the key plate in tiles: median = offset, spread = drift. Needs shared structure between plates
Gain and tone limits
effective Murray–Davies coverage on --patch boxes or a halftoner --wedge, measured on the solid's densest channel
The draft's provenance is DRAFT; change it to MEASURED only after checking the numbers against the scan.
Closing the gain loop. Print a film sheet's step wedge on the press, scan it, and calibrate:
This writes a copy of the profile with only what the wedge measures replaced: substrate.gain (measured pairs), min_dot and max_dot, with yule_nielsen_n set to 1 because the pairs are effective coverage. The scan is appended to measurements, and provenance becomes DRAFT, naming the scan and keeping the original's provenance for everything else. Jobs built from it compensate for the press's real gain. Not measured: dot shape (set it from a loupe crop), slur, density variance.
Validated against renders with known parameters (tests/test_measure.py): ruling within 1%, angle within 0.5°, ink colors, registration offsets within 0.05 mm, gain within 2 points.
Constraint policy
Check
screen
svg
pod
film
pdf
Ruling ≤ substrate ceiling
report
report
cap
refuse
refuse
Smallest dot printable
report
report
report
refuse
refuse
Sampling ratio ≥ 1.0
report
report
report
refuse
refuse
Ink angle separation ≥ 15°
report
report
report
refuse
refuse
Geometry count
–
warn
–
–
–
Coverage target reached
report
report
report
report
report
Total area coverage ≤ substrate TAC
report
report
report
refuse
refuse
Grid rows lock (non-square repeat)
report
report
report
report
report
Underbase choke reaches one cell
report
report
report
report
report
PDF/X
--target pdf writes PDF/X-1a:2001 (ISO 15930-1) separations for a printer:
One Separation color space per ink, named for the ink; every dot is a 100% tint, so the RIP images the screen as drawn and never re-screens it. Each separation's DeviceCMYK alternate is a naive conversion for on-screen proofing only.
Overprint on (OP, op, OPM 1) for everything: plates never knock each other out, and the press makes the overprint colors. A chosen overprint color is a property of the real inks, so it's recorded in the document's Keywords rather than encoded.
TrimBox = canvas, BleedBox = canvas + bleed, 12 mm slug with crop marks and registration targets in the All color. Dots are clipped at the bleed.
OutputIntent names a registered characterization without embedding a profile: FOGRA39 (coated), FOGRA29 (uncoated), IFRA26 (newsprint). Set it per substrate (output_condition) or with --output-condition.
No press artifacts, no fonts, no transparency; PDF 1.3.
Two ways to carry the screen (--pdf-mode):
vector (default): every dot a path. Exact at any output resolution, but size grows with dot count, so it suits small or coarse jobs.
bitmap: each separation a 1-bit image mask over the bleed box at --bitmap-dpi (default 2400, platesetter resolution), rasterized and compressed strip by strip straight to disk. The usual way pre-screened art ships; use it for posters and fine rulings.
Art runs into the bleed: plates are built out past it, and photos fill canvas plus bleed unless given a box.
Built to the standard and checked structurally and by rendering (tests/test_pdf.py), but not yet run through a PDF/X preflight (Acrobat Preflight, callas pdfToolbox). Preflight before sending a job.
--force / render(force=True) renders past a refusal and marks it in the outcome.
Status against the spec
#
Feature
State
1–9
Tone chain, ruling/angle/origin, dot shapes with joins, tone limits, gain curves, constant/gradient/region sources, single path per ink, sampling-ratio report, seeds
done
10–11
Ink set, overprint overrides, composite in ink space
done
12
Press artifacts
done: misregistration + drift, slur, density variance, trap gaps
13
Anti-curves, tone compression
done
14
Substrate / press profiles
done, nominal only until measured
15
Constraint engine
done: computed, reported, policy-gated per target
16
Supersampled raster export
done
17
Step wedge emitter + curve ingest
done: wedge on film sheets; halftoner calibrate writes measured gain and limits into a profile
Underbase generation with choke, separately gain-compensated
done
20
Garment color as the compositing base, tone limits relative to it
done: garment is the paper, opaque inks cover it, tone maps between base and solid as printed
21
Grid-commensurate ruling
done: rational angles, a whole number of repeats per module
23
Hybrid AM/FM and blue-noise FM
not yet
—
Print-on-demand size bands and hard alpha
done
—
CMYK process separations
done: device separation with GCR and TAC; DeviceCMYK in PDF/X
—
resvg rasterizing
not planned: see below
Why not resvg. The spec asks for compositing in ink space, never RGB layers with multiply. resvg would rasterize the SVG's multiply-blend preview, which is exactly that. The screen and pod targets already composite separations properly (overprint overrides, opacity, press artifacts), and large vector jobs belong in --target pdf --pdf-mode bitmap. SVG stays a preview and an editable vector export.