Skip to content

Cyanotype printing - #802

Merged
marcinz606 merged 3 commits into
mainfrom
feat/cyan
Aug 11, 2026
Merged

Cyanotype printing#802
marcinz606 merged 3 commits into
mainfrom
feat/cyan

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Extends the alternative-processes work from #798. The Lith collapsible becomes Alternative Processes with a None / Lith / Cyanotype selector; only the selected process's controls are shown.

Why it is its own stage

A cyanotype is not a silver print with a blue curve on it. Iron sensitiser, Prussian blue image substance, no development stage to time. It is fixed by how much UV got through the negative and by how long a density range the paper can hold.

Data model

LithConfig is replaced by one AltProcessConfig with an alt_process enum instead of a bool per process — two booleans would have made "both on" loadable from a sidecar or preset. lith_enabled migrates in migrate_flat_config; the three lith sliders keep their names and values, so existing edits open unchanged.

features/altprocess/ holds only the config, because the panel is shared; the logic and shaders stay in features/lith/ and features/cyanotype/. Noted in CLAUDE.md as the one exception to the feature pattern.

Controls, and where the numbers come from

Control Source
Sensitiser — Classic (Herschel) / New (Ware) Ware's densitometry: red-channel Dmax 0.55–1.05 for the classic sensitisers, ~1.5 for a good modern print. Classic 0.95 and greener, New 1.40 and deeper
Exposure Scale 0.8–2.8 log D The negative density range the sensitiser prints. Ware: ~1.0–1.2 traditional vs 2.4 new; Simple Cyanotype ships at 1.8 / 2.3 / 2.7
Exposure −2 to +4 stops Time under the UV source
Bleach / Tannin The two trays: washing soda strips pigment highlights-first, tannic acid re-develops the iron as a brown tannate

Exposure Scale runs the opposite way to intuition: a longer scale prints softer, because a negative that doesn't fill the scale prints flat. That is what drives new-cyanotype printers to make denser negatives. Measured on a real scan, gamma over a fixed band of the untouched print:

scale 1.0  gamma 0.63   blocked at Dmax 23.3%
scale 1.8  gamma 0.35   blocked  4.3%
scale 2.6  gamma 0.30   blocked  0.0%

The transfer

Within the scale the midtones compress — the reason a cyanotype digital-negative correction curve exists at all. Written as a reverse-S using v·|v| (v = 2t−1) rather than a power, so both engines evaluate it identically, blended against a straight line so mid-slope is 0.55 instead of zero; a perfectly flat midtone posterizes a gradient.

Colour follows lith's construction — an (a*, b*) path indexed on output density with L* from the density itself. Prussian blue absorbs around 700nm, so the print never goes black, it goes blue. The highlight knot is green because Ware describes the residual yellow sensitiser colour-mixing with the blue on both formulas.

Toning

No silver anywhere in a cyanotype, so the six chemical baths are skipped entirely on both engines while it is selected, and greyed out in the panel. Split toning is a dye stain and still applies. toning.wgsl's lith_active flag becomes a three-state alt_mode.

Deliberately not modelled

Solarisation. It is real, and it is what everyone photographs mid-process, but Ware is explicit that the reversed shadows regain their density on drying as Prussian white oxidises back to Prussian blue — the finished print does not carry it. Bronzing is a surface sheen rather than a colour, so it is out too.

Verification

Headless drive of the real app on a B&W scan:

none      shadow Lab=[12.0  0.0   0.0]   highlight Lab=[92.2  0.0  0.0]
lith      shadow Lab=[ 5.2 -0.1   0.3]   highlight Lab=[81.6  4.0  8.0]
cyanotype shadow Lab=[40.0 -6.0 -25.0]   highlight Lab=[92.6 -2.2  4.8]
cy+tannin shadow Lab=[34.6  7.5  15.5]   highlight Lab=[91.7  0.5  2.1]

CPU and GPU agree across every cyanotype case. New tests: test_cyanotype_logic.py, test_altprocess_config.py (the lith_enabled migration), test_altprocess_sidebar.py, TestCyanotypeParity, cyanotype rows in the bit-identity stage-skip sweep, and inert-toner tests. make all green.

One fix worth calling out: the sensitiser combo stores plain strings as item data while the config holds a StrEnum, so findData(enum) missed and sync_ui snapped the dropdown back to Classic on every render. Same QVariant gotcha the Lab sharpen-method combo already has a test for; pinned with one here too.

Known gap

PrintService._toned_paper_white derives the mat / filed-carrier colour from the toning stack only, so it ignores cyanotype — as it already ignores lith. The cyanotype paper base is near-white, so the mismatch is small. Left for a follow-up.

A cyanotype is not a silver print with a blue curve on it. The sensitiser is
iron, the image substance is Prussian blue, there is no development stage to
time, and the whole thing is fixed by how much UV got through the negative and
by how long a density range the paper can hold.

Rename the Lith collapsible to Alternative Processes and put a None / Lith /
Cyanotype selector at the top of it, showing only the selected process's
controls. The two are mutually exclusive - you cannot lith-develop a cyanotype -
so LithConfig is replaced by one AltProcessConfig carrying an alt_process enum
rather than a bool per process, which would have made "both on" loadable from a
sidecar. lith_enabled migrates in migrate_flat_config; the three lith sliders
keep their names and their values.

The controls come from Ware's measurements. Exposure Scale is the negative
density range the sensitiser prints, in log D: he puts the traditional Herschel
formula at about 1.0 to 1.2 against 2.4 for his own, and his Simple Cyanotype
ships as three variants at 1.8, 2.3 and 2.7. Note the direction - a longer scale
prints *softer*, because a negative that does not fill the scale prints flat.
Measured on a real scan, gamma over a fixed negative band runs 0.63 at scale 1.0
down to 0.30 at 2.6, with the blocked-at-Dmax fraction falling 23% to 0%.

Within the scale the midtones compress, which is the reason a cyanotype
digital-negative correction curve exists at all. Modelled as a reverse-S written
v*|v| rather than a power, so both engines evaluate it identically, blended
against a straight line so the slope at the centre is 0.55 instead of zero - a
perfectly flat midtone posterizes a gradient.

Colour follows lith's construction: an (a*, b*) path indexed on output density
with L* taken from the density itself. Prussian blue absorbs around 700nm, so
the print never goes black, it goes blue; the highlight knot is green because
Ware describes the residual yellow sensitiser colour-mixing with the blue on
both formulas. Sensitiser picks Dmax and the path - Classic 0.95 and greener,
New 1.40 and deeper, matching his densitometry.

Bleach then tannin is the standard toning pair, so it gets two sliders rather
than one: washing soda strips pigment highlights-first, tannic acid re-develops
the iron as a brown tannate that covers more than the pigment it replaced. A
partial bleach leaves a split blue-brown.

There is no silver anywhere in a cyanotype, so the six chemical baths are
skipped entirely on both engines while it is selected and greyed out in the
Toning panel. Split toning is a dye stain and still applies. toning.wgsl's
lith_active flag becomes a three-state alt_mode.

Not modelled: solarisation. It is real and it is what everyone photographs
mid-process, but the reversed shadows regain their density on drying as Prussian
white oxidises back to Prussian blue, so the finished print does not carry it.
Bronzing is a surface sheen rather than a colour, and is also left out.

CPU and GPU agree across every cyanotype case.
@marcinz606
marcinz606 marked this pull request as ready for review August 11, 2026 17:33
@marcinz606
marcinz606 merged commit cb876d4 into main Aug 11, 2026
1 check passed
@marcinz606
marcinz606 deleted the feat/cyan branch August 11, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant