Art pipeline v1: derivatives, measurement and the reactive ground
Status: version 1, 2026-10-30. This is a first, documented reading of eleven pieces, meant to grow as new work arrives. It implements decision Q2 / Q6 (“reactive ground”) from docs/study/2026-10-30/05-ethos-and-direction.md §6a, and ports the extraction method of that study (§0) from its Python scripts to Node.
The colours here are an interpretation of the work, not facts about it. The pipeline measures pixels by a stated method and applies stated rules. A different ring width, threshold or colour space gives different numbers. Pixel dimensions and colour profiles are facts; grounds, palettes and inks are readings.
Where things are
| What | Where |
|---|---|
Originals (never committed, never copied into public/) |
archive/input_images/IMG_*.PNG (gitignored) |
| Hand-edited words and bodies of work | src/data/art/catalogue.json |
| Generated catalogue (do not edit by hand) | src/data/art/pieces.json, typed by src/data/art/index.ts |
| Derivatives (committed, served) | public/art/<id>/<id>-<width>.{avif,webp} |
| Script | scripts/art/build-art.mjs, with scripts/art/lib/{analyse,rules,colour}.mjs |
| Words for Aleksandra to edit | docs/art/words-to-edit.md |
| Viewer and rail | src/components/art/PressBed.astro, InspectionRail.astro, ArtPicture.astro |
| Lab pages | /lab/art/, /lab/art/<id>/, and this file at /lab/art/method/ |
Rerunning
Requirements: Node 22 (source ~/.nvm/nvm.sh && nvm use in the repo), the originals in archive/input_images/, and sharp, which is already installed as a dependency of Astro. Nothing needs installing.
node scripts/art/build-art.mjs # derivatives + measurement + pieces.json + results table below
node scripts/art/build-art.mjs --force # re-encode every derivative
node scripts/art/build-art.mjs --only=img-0079
node scripts/art/build-art.mjs --analyse # measurement only; fails if a derivative is missing or stale
node scripts/art/build-art.mjs --words-doc # also regenerate docs/art/words-to-edit.md from the catalogue
ART_SRC=/path/to/originals node scripts/art/build-art.mjs
The run is idempotent. A derivative is re-encoded only when its original (size and modification time), its target size or the encoder settings change. The cache lives in node_modules/.cache/ophelia-art/, so it is never committed; without it, the first run re-encodes everything (about four minutes on an M-series Mac). The script writes each file through a temporary name, removes derivatives that the current ladder no longer produces, and writes pieces.json only when its content changes. Measurement is deterministic: the same originals always give the same pieces.json.
If the originals are missing, the script stops and changes nothing. Commit public/art/ and src/data/art/pieces.json after a run; the deployed site never needs the originals.
To add a piece: put the original in archive/input_images/, add an entry to catalogue.json (file, body, rhematic, halftone, words with altStatus: "draft"), rerun.
Derivatives
Viewer. From the pixel dimensions alone: scroll when height ÷ width ≥ 2, pan when width ÷ height ≥ 2, otherwise fit (05 §3, move 2).
Ladder. Each viewer sizes the art by a different axis, so each has its own rungs. Every rung is capped at the original; nothing is upscaled; duplicate rungs collapse.
| Viewer | Axis | View rungs | Detail derivative |
|---|---|---|---|
| fit | long side | 640, 1280, 2048 | the original’s long side up to 3200, when the original is larger than 2048 |
| scroll | width | 640, 1280, 1500 | none (see Limits) |
| pan | long side | 1280, 2048, 3200 | the original, up to 4096 |
Halftone pieces (img-0079, img-0083, img-0087, as read in the 2026-10-30 study) use exact integer fractions of the original instead (½, ⅓, ¼, ⅙ …, only divisors that divide both sides), nearest to each rung, and their detail derivative is the original size. So img-0079’s derivatives are 600 × 750, 900 × 1125, 1800 × 2250 and 3600 × 4500: each a clean 1/6, 1/4, 1/2 and 1/1.
Formats.
- AVIF: quality 50, effort 6, 8-bit, 4:4:4 chroma (no chroma subsampling, so 1–2 px channel fringes and dot edges stay crisp). Every rung, and the detail derivative.
- WebP: quality 76, smart subsampling, as the fallback for browsers without AVIF. To keep inside the budget, WebP ships for every view rung except the largest (pieces with a single rung keep it), and not for the detail derivative. A browser without AVIF therefore gets at most the middle rung.
Colour. Seven originals carry a Display P3 profile (img-0019, 0079, 0080, 0083, 0084, 0087, 0089); four are untagged sRGB (0031, 0082, 0085, 0090). P3 derivatives keep the P3 pixel values and embed the original’s Display P3 ICC profile (sharp keepIccProfile), so magentas and cyans outside sRGB are not clipped. sRGB pieces stay untagged, which browsers treat as sRGB. Alpha is dropped: every original was measured fully opaque.
Checks on every run. For each derivative the script reads it back and fails if its pixel size is wrong, if a P3 piece lost its Display P3 profile, or if an AVIF is not 4:4:4 (read from the av1C box: profile 1, no subsampling).
Budget. Everything in public/art/ must stay under 30 MB; the script prints the total and exits with status 2 if it is over. Version 1 is 23.63 MB (per piece in the table below).
Measurement
All measurement runs on an sRGB raster made from the original: alpha removed, resized with Lanczos 3 so the long edge is 480 px, and converted to sRGB (Display P3 originals are converted for measurement only; their derivatives stay P3). The study used the 900 px JPEG previews at 240 px; 480 px keeps more of the thin strip (img-0084 becomes 60 × 480).
| Measure | Definition |
|---|---|
| Edge, per side | The per-channel median of the outer 6 px ring of that side (the study’s 3 px at 240 px). Top and bottom rings span the full width; left and right span the full height. Also a median of all four rings together. |
| Luminance | WCAG relative luminance of every pixel: median, mean, and the share below 0.08 (“dark”). |
| Saturated band | HSV saturation > 0.45 and value > 0.35 (the study’s thresholds). |
| Muted band | Saturation 0.15–0.45 and value > 0.25, not already saturated. |
| Hue families | 30° sectors from 0°: red-orange, ochre-yellow, yellow-green, green (one 60° family, 90–150°), teal, cyan, blue, violet, mauve-purple, magenta, crimson-rose. |
| Family share | Pixels of that family ÷ pixels of its band. Families under 1 % of their band are left out of the lists. |
| Medoid | The family’s representative colour is a real pixel: the one with the smallest summed RGB distance to the others in a deterministic sample of at most 1500 pixels (seeded by piece, band and family). Never an average. |
| Dominant family | The largest saturated family, or the largest muted family when the saturated band is under 3 % of all pixels. |
Port check. The Node port reproduces the study’s edge readings: img-0079 #a8a8b2 (study #a8a5b0), img-0083 #000d2c (#04102d), img-0089 #16141e (#181620). Differences come from the source (originals, not JPEG previews) and the raster size.
The ground rule
One sentence: start from the body of work (glitch on the bed, figurative and painting on paper), let three or more agreeing edges override it (light, low-chroma edges → paper; dark edges → bed), then move that ground’s lightness, keeping its hue, to the nearest value that is at least 1.3 : 1 against every edge.
- Default by body of work (decision Q2 / Q6): glitch → bed
#1b191b; figurative and painting → paper#f2eee4. - Classify each side’s edge colour. Dark: luminance ≤ 0.05 (about L* 25). Light and low-chroma: luminance ≥ 0.30 (about L* 62) and HSV saturation ≤ 0.25. Otherwise mid.
- Override from the data when at least 3 of the 4 sides agree: 3+ light → paper; 3+ dark → bed. Mixed edges keep the default.
- Adapt the colour. Convert the chosen base to OKLCH and move only its lightness, in steps of 0.001, within a range per kind (bed L 0.14–0.45, paper L 0.78–0.99). Take the value nearest the base whose WCAG contrast against each of the four edge colours is ≥ 1.3 : 1. If the base already clears every edge, it is kept unchanged. If no value in range clears every edge, take the one with the highest lowest contrast and say so in
reason; the frame line then carries the edge. - Text inks on the ground: Paper on the bed, Ink on paper (AA is checked and recorded). The soft text ink (Bed Text Soft
#aaa59cor Ink Soft#4b4c52) is used only if it reaches 4.5 : 1 on the adapted ground; otherwise soft text uses the full ink. - Frame line (DESIGN.draft, Readable Frame Rule). A 1 px line just outside the art, in the quiet ink first (Bed Text Soft on the bed, Ink Soft on paper), then the full ink. It is accepted when, on every side, either the ground or the line reaches 3 : 1 against that side’s edge.
frame.metrecords whether that held. - Focus ring: Signal
#ee4423where it reaches 3 : 1 on the adapted ground, otherwise the text ink.
Each piece’s ground.reason in pieces.json traces these steps in words, and the inspection rail prints it.
The inks rule
inks.drum picks one of the three ink drums (decision Q5) from the palette; inks.accent is the medoid of the dominant family.
- muted when the muted band is at least 1.5 × the saturated band (the piece’s colour lives in the muted range), or when the saturated band has no cool side (cyan, teal and blue together under 5 %).
- fringe when cyan and teal are at least 15 % of the saturated band: the warm/cool fringe that the Fringe drum interprets is present.
- bloom otherwise: a saturated piece led by rose, magenta or red with little cyan.
Drum inks print on paper only (DESIGN.draft). Nothing in this rule ever tints a piece.
Results (v1)
| id | body | pixels | viewer | edges T R B L | ground | lowest edge contrast | frame line | drum | accent | MB |
|---|---|---|---|---|---|---|---|---|---|---|
| img-0019 | figurative | 2064 × 2752 | fit | DDDD | bed #3c393c |
1.30 | #aaa59c |
muted | #a56942 red-orange |
0.54 |
| img-0031 | painting | 2048 × 2048 | fit | mDDD | bed #2c2a2c |
1.30 | #aaa59c |
muted | #853d22 red-orange |
0.52 |
| img-0079 | glitch | 3600 × 4500 | fit | LLmL | paper #f2eee4 |
1.61 | #141519 |
fringe | #3ba3c0 cyan |
4.21 |
| img-0080 | glitch | 3000 × 7000 | scroll | Dmmm | bed #232123 |
1.30 | #aaa59c |
fringe | #942438 crimson-rose |
3.50 |
| img-0082 | glitch | 2048 × 2048 | fit | mDDD | bed #292729 |
1.30 | #aaa59c |
fringe | #2b84af cyan |
2.37 |
| img-0083 | glitch | 3024 × 4032 | fit | DDDD | bed #494649 |
1.30 | #aaa59c |
fringe | #342092 violet |
2.29 |
| img-0084 | glitch | 500 × 4000 | scroll | mmmm | bed #1b191b |
2.85 | #f2eee4 |
bloom | #d24c6a crimson-rose |
1.04 |
| img-0085 | glitch | 4096 × 1714 | pan | DmDm | bed #1b191b |
1.44 | #aaa59c |
bloom | #953d27 red-orange |
4.65 |
| img-0087 | glitch | 640 × 480 | fit | mmmm | bed #1b191b |
2.74 | #f2eee4 |
bloom | #b1298b magenta |
0.22 |
| img-0089 | glitch | 2064 × 2752 | fit | DDDD | bed #4d4b4d |
1.30 | #aaa59c |
bloom | #9b3347 crimson-rose |
2.49 |
| img-0090 | figurative | 3477 × 4822 | fit | mmmD | paper #f2eee4 |
5.11 | #4b4c52 |
muted | #c8683b red-orange |
1.79 |
Edges: D dark, L light and low-chroma, m mid. Total derivatives: 23.63 MB. Generated by build-art.mjs; do not edit by hand.
Limits
- A reading, not the art’s colour. Hue sectors say which families are present, not which colours matter to the eye. A medoid is one real pixel, but choosing it is a method.
- Measured in sRGB. P3 colours outside sRGB are clipped for measurement, so measured medoids of the most saturated P3 pieces are slightly duller than what a P3 screen shows. The derivatives are not affected.
- The ring is a median. A thin border or a single bright corner can vanish in a side’s median. The 6 px ring (1.25 % of the long edge) was chosen to match the study, not tuned per piece.
- Thresholds were set by reading eleven pieces. They are round numbers with stated meanings, but they are a first calibration. Expect to revise them as the body of work grows; record any change here and bump
measured.version. - 1.3 : 1 is a floor for separation, not legibility. It keeps the frame from dissolving. Strongly mixed edges push the bed lighter: img-0083 (one teal side among three near-black sides) and img-0089 get noticeably lighter grey beds. Whether that reads well is a judgement for Aleksandra.
- Lossy derivatives. AVIF at quality 50 is visually close at 1×, but glitch noise is where encoders cut. Halftone moiré at intermediate display sizes is not yet checked by eye at 1× and 2×.
- Large originals. img-0080’s largest derivative is 1500 px wide, half its original; img-0090’s detail is 2307 × 3200, 66 % of its original. A native-pixel tiled zoom is deferred until a piece needs it.
- WebP fallback stops below the top rung, so browsers without AVIF see less resolution.
How it can grow
- A per-piece
groundOverridein the catalogue, recorded with its reason, for the cases Aleksandra judges by eye. - Per-side frame treatment (a line only on sides the ground does not separate).
- Measuring the muted drum’s actual inks from the pooled muted band once values are chosen.
- A moiré check that renders halftone pieces at the sizes the viewer actually uses.