Skip to the method
ophelia.io / press bed

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.

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.

  1. Default by body of work (decision Q2 / Q6): glitch → bed #1b191b; figurative and painting → paper #f2eee4.
  2. 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.
  3. Override from the data when at least 3 of the 4 sides agree: 3+ light → paper; 3+ dark → bed. Mixed edges keep the default.
  4. 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.
  5. Text inks on the ground: Paper on the bed, Ink on paper (AA is checked and recorded). The soft text ink (Bed Text Soft #aaa59c or Ink Soft #4b4c52) is used only if it reaches 4.5 : 1 on the adapted ground; otherwise soft text uses the full ink.
  6. 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.met records whether that held.
  7. Focus ring: Signal #ee4423 where 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.

  1. 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 %).
  2. fringe when cyan and teal are at least 15 % of the saturated band: the warm/cool fringe that the Fringe drum interprets is present.
  3. 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

How it can grow