Skip to content
chuwd19Public

About

Turn photos into risograph-style prints: spot-colour separation, per-ink halftone screening, overprint. Static WebGL2, no build step.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Overprint

Turn a photo into a risograph print, in the browser.

Try it live →

Photograph of Grand Prismatic Spring, Yellowstone
The photograph. Grand Prismatic Spring, Yellowstone: continuous tone, thousands of colours.
The same photograph printed in black, blue and orange halftone inks
Printed. Three inks (Black, Blue, Orange), each screened at its own angle onto natural stock.

Overprint does what a print shop does, not what a filter does: it picks a few spot inks, works out how much of each belongs at every point in the picture, screens each ink at its own angle, and prints them one over another onto paper. That is where the riso look comes from: rosettes where screens cross, ink slightly out of register, colours the inks can't quite reach, paper grain in the highlights.

It is one static page with no build step and no server. Your photo is never uploaded — it is separated, screened and printed on your own machine — and your photo and settings stay in your browser. The page's one outbound request is an anonymous hit to GoatCounter, which sets no cookies and records no personal data; see Analytics.

Every picture on this page can be reproduced: drop docs/images/source.jpg on the sheet and pick the matching preset.

Six treatments of one photograph

Auto-picked inks
What Auto-pick chose
Left to itself the app picked Blue + Fluorescent Pink + Yellow, a process-like triad. Note the lavender cast on the mineral flats: three inks can't reach every colour, and it spends the error on hue rather than tone.
Two-colour poster
Two-colour poster
Blue + Scarlet on natural stock, 34 lpi round dot. Two inks, and the pool still separates cleanly from the mats.
Fluoro zine
Fluoro zine
Fluorescent pink + blue through a grain screen instead of dots, registration deliberately loose.
Kraft duotone
Kraft duotone
Black + Sunflower on kraft, diamond screen, hand-cut edge. Dark paper means highlights can only ever be paper.
Coarse dot
Coarse dot
A single ink at 14 lpi. Big enough that you read the dots before the picture.
Line screen
Line screen
Steel + Crimson through a 40 lpi line screen, each ink at its own angle.

Four more, from the one-bit school

The same photograph again, this time through the screens and tricks of BitCam, the 1-bit Macintosh camera. A press can do all of them: an error-diffusion dither is a screen like any other, and a gradient behind or inside the picture is a flood coat or a split fountain.

One-bit Atkinson dither in black on white
One-bit
Black on bright white through an Atkinson dither at 3.5 px. No grey anywhere: every art pixel is ink or paper, and the pool reads because Atkinson spends its error on edges.
Three one-bit dithers overprinted in blue, pink and yellow
Eight colours
Blue + Fluo Pink + Yellow, each a 1-bit dither, overprinted in register. Paper, three inks, three overlaps and all three together: QuickDraw's eight classic colours, which is what BitCam's colour mode is.
A black dither over a scarlet-to-blue flood
Woodblock
A flood coat graded from Scarlet to Cornflower, then black Atkinson on top. The separation is made against the flood, so it uses far less black than the one-bit print above.
A single dot screen printed in a blue-to-pink fountain
Split fountain
One stencil, cut for Federal Blue, printed with Fluo Pink in the far end of the tray. The middle goes through purple because the blend is done in ink density, not on screen.

How a print is made

The app shows you its own working. The buttons along the top step through the same stages a press goes through.

1. The photo. Whatever you dropped in, with the film look, exposure, contrast and saturation applied. Everything downstream works from this, which is why a look changes the inks and not just the tint.

The source image

2. Separation. The photo resolved into one film per ink: how much black, how much blue, how much orange belongs at each point. Continuous tone, no dots yet. Look at what each film ended up carrying: black took the dark mineral structure and the boardwalk, blue took the pool, orange took the bacterial mats with the pool knocked out of it.

Three continuous-tone ink films: black, blue, orange

3. Screening. Each film broken into dots. Every ink gets its own frequency, angle and dot shape, which is what stops the three screens landing on top of each other and turning into a muddy moiré.

The same three films after halftone screening

4. Overprint. The screened films printed onto the paper in order, each shifted a fraction against the last. Where dots overlap you get colours that aren't in any single ink; the green rim around the pool is blue and orange landing together.

The finished three-colour print

Filters, before the press and after

A library of 122 colour grades in 17 families, named after the shelves a phone editor keeps them on: 自然 Natural, 质感 Texture, 人像 Portrait, 胶片 Film, 轻复古 Retro, 电影 Cinematic, 港风 Hong Kong, 日系 Japanese, 韩系 Korean, 清新 Fresh, 奶油 Cream, 赛博 Cyber, 夜景 Night, 黑白 Mono, 美食 Food, 油画 Oil, 风景 Landscape.

The same library is offered at two points in the press, which are two different jobs:

photo  →  PRE  →  separate · screen · overprint  →  POST  →  sheet

Pre-press — on the photograph, before the separation

A pre-press filter colours the picture the press is asked to match. That changes the separation, so Auto-pick fits its inks to the coloured photo and reaches for different drums. One photo, one three-ink press, four filters — and four ink sets nobody chose:

Ungraded, printed in blue, crimson and orange
No look
Blue + Crimson + Orange. The photograph as it came.
A teal and orange grade, printed in blue, scarlet and yellow
Teal & Orange · 电影
Blue + Scarlet + Yellow. Shadows to teal, highlights to orange, so the fit drops crimson for a scarlet and a yellow it can mix warmth from.
A Kodachrome grade, printed in blue, fluorescent pink and yellow
Kodachrome · 轻复古
Blue + Fluo Pink + Yellow. Deep reds and cold shadows push the fit to the fluorescent, which is the only drum that can reach that magenta.
A red-filter monochrome grade, printed in black, indigo and cornflower
Red Filter · 黑白
Black + Indigo + Cornflower. The look is a channel mix, the way a red filter screwed onto the lens is, so the photo arrives grey and the fit answers with a neutral triad.

Halation rides alongside a pre-press filter, because it is the one thing a colour table cannot hold: it is not a per-pixel map but the red bleed a negative gets where a highlight scatters back off the film base. It is taken from the photo's own mip chain, which is already a blur of itself. Cinestill 800 sets it to 95%, which is the whole reason that stock exists.

Post-press — on the finished sheet, after everything

A press flattens a grade, and there is no way around it: a print has two or three inks, a paper white it cannot go above and a maximum ink density it cannot go below. So a post-press filter colours the sheet that came out instead. No press can do this, which is exactly the point — it arrives exactly as picked, paper and all.

The same look, the same photograph, the same three inks. Only the stage differs:

Platinum before the press: a blue and rust duotone
Platinum, pre-press
The photo arrives neutral, but the press has to render grey out of Blue + Crimson + Orange and cannot quite, so it spends the error on hue: a blue and rust duotone. Measured chroma 50.
Platinum after the press: a true neutral monochrome sheet
Platinum, post-press
The same three inks print the same dots, and the look lands on the result. Genuinely neutral, paper included. Measured chroma 3.

Both can be on at once: colour the photograph to steer the ink choice, then colour the sheet to set the mood. Separations are deliberately left alone — those are plates for a real press, and a stylistic filter has no business on one.

Four of the newer families, shown post-press so they arrive undiluted — the same three-ink print underneath each one — Blue + Crimson + Orange, unchanged:

Neon Alley
Neon Alley
港风
Terminal
Terminal
赛博
Crème
Crème
奶油
Hazy Green
Hazy Green
日系

How a look is built

Not as a stolen table. Each one is a spec — white balance, a pivot contrast curve, a toe and a shoulder, a black lift, split toning, and gaussian bands on hue in Oklab — and src/film.js bakes that spec into a 33³ table mapping sRGB in to sRGB out. The curves hold their endpoints, so nothing clips however hard a look is pushed; the separator would otherwise be handed flat black. Tables are baked once and cached, at about 18 ms each, and uploaded as 8-bit 3D textures — which is what a LUT box gives you, and far more than survives being screened into dots.

Because a look is only ever a table, your own LUTs are first-class. Load a .cube takes any 3D or 1D .cube file — anything you can export from Lightroom or Resolve, or a pack you own — and it joins both pickers as Custom. A table with a domain outside 0..1 is resampled onto 0..1 when it loads, so everything downstream sees the one domain. It is parsed in your browser and kept there.

Screens

Eight ways of breaking a tone into marks, shown on the pool's edge in a single black ink. The first four are amplitude screens at 22 lpi: a regular grid where the marks grow and shrink. The other four are frequency screens: same-sized art pixels, scattered more densely in the shadows.

Dot screen
Dot
Diamond screen
Diamond
Square screen
Square
Line screen
Line
Grain screen
Grain
Bayer ordered dither
Bayer
Atkinson dither
Atkinson
Floyd–Steinberg dither
Floyd–Steinberg

Grain thresholds each pixel against white noise. Bayer thresholds it against a fixed 8×8 matrix, which is why it makes those crosshatch textures. Atkinson and Floyd–Steinberg are error diffusion: each pixel is rounded to ink or paper and the rounding error is pushed onto the pixels not yet visited, so tone is kept over a neighbourhood rather than inside a cell. Floyd–Steinberg passes all of the error on and reproduces tone exactly. Atkinson, Bill Atkinson's kernel from the original Macintosh, deliberately drops a quarter of it, so highlights clip to clean paper and shadows to solid ink instead of filling with stray specks. That contrast is the whole 1-bit look, and it is why the one-bit preset lifts the exposure.

Frequency screens have no angle or frequency; they have a pixel size, in px at 150 ppi. The three quick sizes are BitCam's: Fat bits (6 px, 175 pixels across a 7-inch print), Standard (3.5 px, 300 across) and Fine (2 px, 525 across).

Floods and fountains

Two things a press can do with a gradient that a filter can't.

A flood coat is a layer of ink laid over the whole block before the picture: solid, or graded between two inks in a straight line or out from the centre. It behaves like coloured paper, and the separator treats it that way: the films are made against the flooded stock, so a black ink over a red flood carries only the darkness the red can't. Save separations gives you the flood as its own film, printed first.

A split fountain is two inks in one tray, so a single pass prints one colour at one end of the sheet and another at the other. The stencil is still cut for the first ink; the second is just what comes out at the far end. The blend between them is interpolated in ink density rather than in light, which is what keeps a blue-to-yellow fountain green in the middle rather than grey.

Both take a pair from the same eight quick pairs, or any two inks from the library.

The interface

The Overprint interface: a print on a dark press bed, ink channel controls on the right

The panel is deliberately colourless. The only saturated thing in the interface is each channel's actual ink, so nothing competes with the print you're judging.

Control What it does
? An eight-step walkthrough. A small bubble rings one control at a time and says what it is for, counting 1/8 as it goes, with Skip on every step and ← → Esc on the keyboard. It runs itself once on a first visit and never again; the ? replays it. It rings rather than dims, so the print stays visible, and nothing is blocked while it is up.
Open photo (O) Loads a new photo. You can also drop an image file on the sheet, or paste one from the clipboard.
Rotate, Flip Quarter turns and mirror flips of the photo. Rotating swaps the sheet's orientation with it.
Paper Ten stocks: white, natural, newsprint, grey, kraft, four pastels and black. Dark stock means highlights can only ever be paper.
Ink picker The 78 Riso drum colours in hue order, White included, plus a rainbow swatch that takes any colour from the system picker.
Hiding Per ink, in Customize. How much a solid dot covers the paper rather than filtering it; each ink comes with its own value.
Pre-press filter One of 122 colour grades, laid on the photo before it is separated, so it moves which inks Auto-pick reaches for — at the cost of the press flattening most of it. Strength cross-fades it; Halation adds the red bleed around highlights that a colour table cannot hold.
Post-press filter The same library, laid on the finished sheet instead, after every press stage. It comes through exactly as picked, paper and all, because nothing downstream has to reproduce it. Separations are left unfiltered. Each section carries a small map of the press with its own slot lit, so the pair reads as one choice.
Load a .cube Any 3D or 1D .cube LUT you own, parsed in the browser and kept there. It joins both pickers as Custom.
Auto-pick Searches the 26 common drum colours for a set that suits this photo on this paper. It has two ideas of "suits". Poster (the default) picks what a print shop would reach for: one dark ink to carry the drawing, the rest bright and far apart on the wheel, overprinting to a clean dark — the best-looking set that still reproduces the photo well. Faithful picks whatever reproduces it most closely, however dull; it drifts toward process-like triads. Clicking either chip picks at once.
Screen angle dial Drag it. Hold Shift to snap to 7.5°, or use the arrow keys.
Pixel size For the frequency screens. The Fat bits, Standard and Fine chips are BitCam's three resolutions.
Flood coat Under Paper. None, solid, or a linear or radial gradient between two inks, laid down before the picture.
Split fountain Per ink, in Customize. Blends the channel's ink into a second one across the sheet.
Shuffle (R) Keeps your photo, reprints it with a new look, inks, screen and frame. The inks come from a list of classic riso pairings four times in ten, from a Poster pick three, and from chance the rest; now and then it floods the sheet or splits a fountain.
Misregistration Shifts every ink but the first, the way a second pass through the drum would.
Ink fade The drum running low: density falls away toward the trailing edge, unevenly.
Roller streaks Bands of heavier and lighter ink across the feed, at the drum's rhythm.
Highlight knockout Prints no ink below a chosen coverage, so the lightest tones stay clean paper. Worth having on with dithers.
Marks Crop marks at the corners, and optionally registration targets on each side. They go down with every ink, so with misregistration on you can see the targets split, as on a real proof. Separations carry them too.
Hand-cut edge Roughens the edge of the ink block, like paper torn against a ruler.
Surround Dark, neutral grey or white behind the sheet. The grey is the ISO 3664 viewing grey you'd judge a real proof against.
Save separations One PNG per ink, ready to take to an actual press, plus one for the flood if there is one.

Your photo and settings are kept in your browser and restored when you come back.

Run it locally

git clone https://github.com/chuwd19/overprint.git
cd overprint
./run.sh          # serves on :8123 and opens a browser

ES modules need a real origin, so opening index.html straight from the Finder won't work; hence the tiny server. Any browser with WebGL2 will do (Safari 15+, Chrome, Firefox, and mobile Safari).

To host your own copy, push it anywhere that serves static files. There's no build step; the app itself is 181 KB, plus 66 KB of typeface.

Under the hood

The part worth reading about is the separation, because it's where a print simulator usually goes wrong.

Ink doesn't work like a filter. The obvious model is Beer–Lambert: light passes through ink and gets attenuated, so density adds up. That's true of a solid film, but a screened tint isn't a film. It's a scattering of solid dots with bare paper between them, and what you see is the average. So reflectance is linear in coverage, not exponential. And a dot isn't a pure filter either: ink has hiding power, so a solid dot is the base seen through the ink, pulled toward the ink's own colour. With inks laid down in printing order on paper $R_0 = P$,

$$ \mathrm{dot}_i = (1 - h_i), R_{i-1} \odot C_i + h_i, C_i, \qquad R_i = R_{i-1} + a_i \left( \mathrm{dot}_i - R_{i-1} \right) $$

for ink colour $C_i$ with hiding $h_i$ and coverage $a_i$, the fraction of the area the ink occupies. With $h = 0$ this collapses to the Murray–Davies relation,

$$ R = P \prod_i \bigl( 1 - a_i (1 - C_i) \bigr). $$

Riso inks are glazes, so most sit near that; light inks carry white pigment and hide more, and White is mostly cover, which is the only reason it does anything on black stock.

Getting from a photo to those coverages means inverting the model, a small nonlinear least-squares problem solved per pixel: a least-squares seed from the model linearised around the paper, then four Gauss–Newton steps on the relative residual $(R_\text{target} - R) / R$, which is the density error to first order. The fit is weighted toward luminance, so a colour outside the ink set's reach loses its hue before it loses its tone.

It's worth doing properly. The first version used plain Beer–Lambert and mid-grey came out as #c3adb6, visibly washed out, with every photo needing the density cranked by hand. With the correct model, a single black ink reproduces a grey ramp exactly.

Screening keeps tone honest. A dot's radius is derived from the coverage $d$ it needs to represent, $r = \sqrt{d / \pi}$ for a round dot, rather than being scaled by eye, so tones don't drift as the frequency changes. Past 50% coverage neighbouring dots would merge and the maths stops working, so the shader crosses over to drawing shrinking holes at the cell corners instead.

Error diffusion can't run in a fragment shader. Every other screen decides each pixel on its own, so the whole print is one pass of one shader. Atkinson and Floyd–Steinberg need to know what their neighbours decided, so the renderer takes a detour: it runs the same shader with one fragment per art pixel and asks only for that ink's coverage, reads the result back, diffuses it on the CPU (src/dither.js, which has no browser dependencies), and hands the 1-bit map back to the print pass as a texture. The art-pixel grid is sized in physical units like everything else, so the preview and a 3× export have the same number of art pixels and the same dither pattern.

Everything is in physical units. Frequency is lines per inch against a 7-inch long edge, so a 3× export has the same physical dot size as what you saw on screen, just with more pixels describing it.

Auto-pick is a search, not a heuristic. It scores every candidate ink set by how closely it reproduces a sample of your photo's pixels, in Oklab. Every pair and every triple is screened on a few of the samples, the survivors re-scored on all of them, and the best triple is refined slot by slot until no swap helps; on the 26-ink pool that lands on the same answer as an exhaustive search. It runs in a worker so the page stays live. The pool is the common drum colours rather than the full library: the tints and greys add little, and White in particular would be chosen on pale stock to brighten highlights beyond the paper, which the model allows and no print does. Shuffle draws from the same pool, and waits for the fit before it changes the print.

Faithful is the wrong target for riso. Minimising reproduction error picks the inks that best reproduce the photograph, and a riso print is good precisely because it doesn't: two bold inks far apart on the wheel, one dark to draw with. Left to fidelity alone, the search drifts toward process-like triads that reproduce well and look like a mediocre CMYK print. So Poster mode multiplies each candidate's reproduction error by 1 + 2.5 × taste, where taste is a penalty computed from the inks alone, before any photo is considered: it rises if no ink is dark enough to carry the structure, if the chromatic inks sit close on the wheel, if they are pale, if two of them are neutral, if they are all the same lightness, or if their full overprint is mid-toned mud rather than a clean dark. Zero for Black + Fluo Pink; 1.7 for Brown + Light Gray. The weight is set where a pale summer landscape stops being picked as three pastels and before every third photo comes back as the same Blue + Fluo Pink + Yellow. On the demo photograph the two modes differ mildly on two inks — Blue + Scarlet against Medium Blue + Orange — and plainly on three: Faithful takes Blue + Crimson + Orange, which nails the mats and puts two inks 26° apart; Poster takes Blue + Fluo Pink + Yellow, a hair less faithful and a great deal more like a print.

The Faithful pick: blue, crimson and orange
Faithful · Blue + Crimson + Orange
The triad that reproduces the photograph most closely. It nails the bacterial mats, and puts two of its three inks 26° apart on the wheel. Error 0.0023, taste 1.33.
The Poster pick: blue, fluorescent pink and yellow
Poster · Blue + Fluo Pink + Yellow
Same press, same photograph. Error 0.0024 — a tie — and taste 0.14: one dark ink to draw with, two bright ones 106° apart, overprinting to a clean dark.

The classic pairings Shuffle deals from are listed in CURATED in src/inks.js: seven single inks, twenty pairs and ten triads a print shop reaches for without thinking. Poster mode tends to land on them of its own accord, which is a reasonable check that the penalty is measuring the right thing.

Project layout

index.html        page shell
src/style.css     interface
src/fonts/        Archivo, self-hosted (SIL OFL)
src/color.js      sRGB ↔ linear, Oklab, small matrix inverse
src/inks.js       ink library, paper stocks, classic pairings, presets
src/film.js       the 122 looks, the grade that bakes them, and the .cube parser
src/separate.js   the separator and Auto-pick; runs standalone in node, too
src/autopick-worker.js  Auto-pick off the main thread
src/dither.js     error-diffusion screens; also standalone
src/render.js     WebGL2 renderer and the shader
src/tour.js       the optional first-visit walkthrough
src/app.js        state, controls, export, session storage
docs/images/      everything on this page, including the source scan

src/separate.js and src/film.js have no browser dependencies, so you can import them in node and check the colour maths for yourself — including baking a look's table and reading values out of it.

Not implemented

Bleed and true-size imposition. Saving your own presets, deliberately: the site keeps no account and records nothing. Metallic inks print as flat colour; there is no sheen.

Filters do not stack within a stage: one pre-press and one post-press is the most you get, and there are no blend modes between them. Film grain is not among them either — a look is a colour table and grain is spatial, so it would have to be its own thing; the paper grain and the ink mottle under Paper and Press are the grain this app has.

Analytics

The page loads GoatCounter, which records one hit per visit: the path, the referrer, and a coarse browser and country. No cookies, no fingerprint, no identifier that persists between visits, and nothing at all about your photograph — the picture never leaves your machine, so there is nothing about it to send. The counts go to the same place as the rest of chuwd19.github.io.

The script is async and entirely separate from the press. Block it, and the app works exactly the same; the two lines that load it are the last thing in index.html, so deleting them is the whole removal.

Licence

MIT; see LICENSE. The demo photograph is public domain (see Credits).

Credits

Inspired by tone and BitCam. tone does the same job on iOS and is worth your money if you're on a phone. BitCam, the Iconfactory's (now Héliographe's) Macintosh camera, is where the dithers, the pixel sizes and the idea of a gradient behind or inside a 1-bit picture come from. It does one thing and does it perfectly.

The ink colours are the Riso drum list kept by stencil.wiki, as collected in riso-colors by Matt DesLauriers.

The interface is set in Archivo by Omnibus-Type, under the SIL Open Font License, and served with the page rather than from a font CDN.

The demo photograph is Grand Prismatic Spring, Yellowstone National Park, by Jim Peaco for the National Park Service. As a work of the US government it is in the public domain. It was picked because it is an ordinary photograph that happens to run the whole way from deep blue through cyan, green and yellow to rust, which is exactly the kind of thing a three-ink press has to fight.

About

Turn photos into risograph-style prints: spot-colour separation, per-ink halftone screening, overprint. Static WebGL2, no build step.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages