Source Model — Image enhancement: page images made easier to read, as nodes, editor tools and renditions — Design Spec (#6185)
Milestone: image-enhancement Manual: TBD — a section, “Enhancing page images”, explaining what Fichero can do to a photograph or scan before reading it (crop to the page, straighten, split, lift the ink, even out stains, quiet ruling and show-through), that the original is never changed, how to preview and undo it on one page or a folder, and how to see whether it helped.
Design-led (Testing Constitution). Creative director owns intent; tests enforce it; code makes them pass. Status: DRAFT — ruled 2026-10-29, ready to build; the APPROVED tag waits on the manual section and the first pinning test (drafted 2026-10-26; trimmed 2026-10-29 to its own line, see the review
agent-work/status/briefs/imgprep-review-2026-10-29.md; the owner’s ruling of 2026-10-29 that the FULL OpenCV suite comes over, as automatic steps and as editor options, folded in the same day: see “The suite, step by step”). This spec owns the pixel steps that write renditions (contrast, yellowing, sharpen, stains, ruling, show-through, binarise, denoise, the show-through-only verso, the working rendition, the reader prompt fragments) and the rule that one function stands behind every door.image-preparation.md(#5382) owns the Set Up step: finding the page, the page box, split, turn, straighten, blanks, the worklist, the detectors and their training, the time shown before Start (prep.*).../ui/preview-image-editing.mdsection T owns the editor’s tools (preview.edit.tool.*). Where a line below was a twin of aprep.*line it now says so and defers; the id is kept so the issues that cite it resolve.recipe-designer.mdowns the Leave Out stage (designer.clean.leaves-out-non-text, #6070). Tags: [OK] built · [GAP] not built · [PARTIAL] exists elsewhere / not wired · [BROKEN] built, wrong.
Intent
The archive’s pages are hard: a wooden ruler and a colour card beside the paper, a wide dark mount, torn and holed edges, water and mould stains, faint ink, green ruling, revenue stamps over the text, and the other side’s writing showing through mirrored and faint. The old command-line Fichero cleaned such pages with OpenCV and the result was good enough to read for years; the new app has an Enhance button that does less, three sliders over PIL. This spec ports the methods and parameters of the old tools into the engine as typed steps written to the app’s rules, and leaves the old app’s shape behind. The owner’s ruling of 2026-10-29: the whole suite, not levels; every step both runs by itself as a recipe step that makes a rendition and is a control in Preview’s editor with a slider and before/after. Port, do not rewrite: each step below names the function it comes from.
Two steps, kept distinct (owner 2026-10-26):
- Image enhancement (this spec) makes a page easier to read and writes renditions: crop to the page, rotate and deskew, split a spread, lift contrast, even out stains, quiet ruling, suppress show-through. It never decides what is ignored.
- Leave Out (
recipe-designer.md, #6070,recipes/leave_out.py) decides what is not read: the ruler, the colour card, the mount, specks, show-through lines, stamps where wanted, show-through-only versos. It never changes pixels.
Order of a run: enhance → find regions and lines (one pass) → leave out → read. Enhancement writes the
page_box Leave Out reads and the rendition whose ink contrast Leave Out measures.
What exists today
- Three systems the steps must plug into.
- Workflow nodes (
workflows/tools/*_images.py, registered inregistry_builtins.py):split_pages,split_images,rotate_images,deskew_images,auto_crop_border_images,remove_background_images,adaptive_binarize_images,denoise_images,enhance_images,fuzzy_clean_images,prepare_images; each runs on a page, folder or project and persists its outputs as renditions throughimage_edit_chains.persist_workflow_renditions(paired by source path, bytes copied under library storage). Pinned bytests/unit/workflows/test_*_images.py. - Editor operations (
api/routes/ingest/image_editing.py,preview.edit.*): a per-page chain of reversible rows; crop, rotate, straighten, enhance, remove background, segment, flip, auto levels, grayscale, auto crop border, adaptive binarize, denoise, fuzzy clean; preview rendered on demand; batch-apply over a folder for crop and split with whole-batch undo (#1265). - Renditions (
models.Rendition:id, document_id, role, path, is_primary;api/routes/document/renditions.py; the app’sRenditionServiceflips “from least to most processed” and a reader records therendition_idits geometry was measured on,llm/kraken_runtime.py). The import makes a thumbnail (1024 px) and a display image (2200 px,db/storage.ensure_display) per page. - The Enhance button today (editor and engine,
_apply_operation("enhance")): PIL only — optional median denoise, optionalImageOps.autocontrast, thenImageEnhance.Brightness,Contrast,Sharpnessby three sliders (default 1.0 each: a no-op).enhance_imagesthe node is the same two PIL calls. Nothing local: a stain and the ink beside it get the same stretch. - Leave Out (
recipes/leave_out.py, #6070): reasonstoo small,off the page,likely bleed-through;off the pageneeds the document’spage_boxmetadata and nothing writes it today;likely bleed-throughneeds an ink-contrast reading of the line and is skipped where there is none. - Reader prompts:
llm/line_reader.PROMPTalready says answer null for a ruler, graph paper, blank paper, a smudge and leave out fragments of lines above and below; it says nothing about mirrored show-through or stamps. The wording is a constant, not versioned, not recorded on the run, not editable per project.recipes/review_profiles.review_promptis the only per-lens prompt builder. - Leaves and blank versos:
finding-documents.mdpairs pages into leaves before anything else; a blank verso is not a document and show-through on a blank verso “can be read mirrored and matched to its recto” (§ leaves). Nothing marks a page as show-through-only yet.
What exists and what changes
Every piece of code that touches a page image today, and what this spec does to it. “Upgrade” means the existing door stays and calls the new function with the same parameters; “replace” means the new step takes the door and the old code survives only for chains that already carry it.
| Where | File | Today | Change | Behaviour | Issue |
|---|---|---|---|---|---|
| Editor: Crop | fichero/fichero/Views/Preview/ImageEditor/ImageEditorView+Toolbar.swift, engine _action_crop in fichero-server/src/fichero_server/api/routes/ingest/image_editing.py |
a hand-drawn box, reversible row | becomes the page-box tool with handles and Find Page (../ui/preview-image-editing.md, preview.edit.tool.crop-is-a-page-box) |
prep.find-the-page |
#6188 |
| Editor: Rotate, Straighten | same files, _action_rotate, straighten |
quarter turns; straighten by a typed angle |
Straighten becomes a dial with Auto; the angle popover goes (preview.edit.tool.straighten-is-a-dial); the angle is recorded (prep.straighten-and-flatten) |
image.enhance.rotate-deskew |
#6190 |
| Editor: Enhance | ImageEditorView+Popovers.swift (three sliders + Auto Levels), engine _apply_operation("enhance") |
PIL brightness, contrast, sharpness, autocontrast, median denoise | replace: Auto Enhance (one click, contrast at its defaults) and Adjust (sliders per step, live before/after, preview.edit.tool.adjust-previews-live-before-after); old op kept as legacy_enhance for existing chains only |
image.enhance.editor-enhance-is-the-new-step |
#6190 |
| Editor: Remove Background | _remove_background in image_editing.py, media/image_ops.remove_black_background_opencv, remove_scan_background |
black-mount mask, scan-border mask | leaves the toolbar; becomes the dark-mount method of find-the-page (prep.page.remove-background-is-the-dark-mount-method) |
preview.edit.tool.retired-ops-still-render |
#6188 |
| Editor: Segment, Fuzzy Clean | _segment_image, media/image_ops.apply_fuzzy_clean |
cuts strips; a local “fuzzy” clean | both leave the toolbar: Segment changes no pixel (regions are Find Regions’); Fuzzy Clean folds into stain-flatten; old rows still render (preview.edit.tool.retired-ops-still-render) |
image.enhance.stain-flattening |
#6195 |
| Editor: flip, auto levels, grayscale, auto crop border, adaptive binarize, denoise | _PARAMETERLESS_OPERATIONS, DenoiseOperationRequest |
parameterless rows | keep; auto crop border calls the fast detect_content_bbox |
image.enhance.rotate-deskew |
#6190 |
| Editor: batch over a selection | ImageEditorModel.batchApply, POST /api/images/batch-apply, …/{batch_id}/undo, /crops/batch |
crop and split only, whole-batch undo | upgrade to every step, three-page preview, per-page record; the sheet is preview.edit.tool.*’s Apply to Selection; a Set Up correction applied to a folder uses it (prep.check.apply-correction-to-the-folder) |
image.enhance.batch-preview-undo |
#6192 |
| Editor: chain, preview, revert | GET/PUT/DELETE /{id}/edits, /preview, RenditionService+EditStates.swift |
reversible rows, on-demand preview, cache | keep; a row gains method, confidence, who |
image.enhance.rendition-records-its-steps |
#6191 |
Nodes: enhance_images, prepare_images |
fichero-server/src/fichero_server/workflows/tools/enhance_images.py, prepare_images.py |
PIL contrast/sharpness; grayscale + autocontrast | replace by the contrast node; prepare_images becomes the ordered sequence of the new steps |
image.enhance.contrast-clahe |
#6190 |
Nodes: deskew_images, rotate_images, auto_crop_border_images |
workflows/tools/deskew_images.py, rotate_images.py, auto_crop_border_images.py; media/image_ops.detect_deskew_angle, detect_content_bbox |
Hough median; EXIF or typed angle; per-pixel bbox loop | upgrade: call the one function; bbox in NumPy | image.enhance.rotate-deskew |
#6190 |
Nodes: split_pages, split_images |
workflows/tools/split_pages.py, media/page_split.py; split_images.py |
Vision outline + darkest column; fixed grid | upgrade split_pages (neighbour test); keep split_images; owned by prep.split-at-the-gutter |
image.enhance.split-spread |
#5382 |
Nodes: remove_background_images, adaptive_binarize_images, denoise_images, fuzzy_clean_images |
workflows/tools/*.py |
one op each | remove_background → page-crop’s dark-mount method; binarize and denoise keep; fuzzy clean → stain-flatten |
image.enhance.stain-flattening |
#6195 |
| Nodes: persistence | workflows/tools/image_edit_chains.persist_workflow_renditions, workflows/tool_outputs.py |
pairs outputs by source path, copies bytes, one rendition per page | keep; writes role: enhanced and metadata.steps |
image.enhance.rendition-records-its-steps |
#6191 |
| Recipe stage: Prepare Images | fichero-server/src/fichero_server/recipes/stages.py (prepare-images), assemble.py (split-pages built-in), jobs.py (prepare-the-image) |
the answer is stored; only split-pages runs |
owned by image-preparation.md: three questions suggested from a sample, what runs by itself, the time before Start (prep.setup.*, prep.estimate.*) |
image.enhance.onboarding-step |
#5580 |
| Recipe stage: Leave Out | fichero-server/src/fichero_server/recipes/leave_out.py |
too small, off the page (needs page_box, never written), likely bleed-through (needs contrast) |
upgrade: reads page_box from page-crop, contrast from the rendition, new reasons |
image.enhance.leave-out-reads-the-page-box |
#6198 |
| Reader prompt | fichero-server/src/fichero_server/llm/line_reader.py PROMPT, _page_context_uri |
one constant; page context re-rendered from the full page per batch | upgrade: fragments appended by id; page context from the working rendition | image.enhance.reader-prompt-fragment, image.enhance.working-rendition-once |
#6199, #6200 |
| Derivatives at import | fichero-server/src/fichero_server/importers/derivatives.py _thumbnail_stage_body, queue_derivatives; db/storage.ensure_thumbnail, ensure_display, _generate_image |
Image.open the full file then img.thumbnail(LANCZOS); cache keyed by document id and path; no draft(); display 2200 px made the same way; a run or a view can call ensure_* again |
upgrade: ONE working rendition, rendered once with shrink-on-load, keyed by content hash and size; thumbnail cut from it; nothing re-renders | image.enhance.working-rendition-once |
#6200, #6193 |
| Display in the app | fichero/fichero/Services/RenditionService.swift, RenditionService.swift |
fetches display/thumbnail by document id | keep; reads the working rendition by its key | image.enhance.working-rendition-once |
#6200 |
| Legacy | fichero_archive/_archive/fichero_legacy/tools/* |
not in the engine | never imported; methods restated | image.enhance.old-conventions-left-behind |
#6194 |
The legacy methods, read closely (2026-10-26)
fichero_archive/_archive/fichero_legacy/tools/ — a CLI app with an assets/<stage>/ folder per step and
a JSONL manifest per stage. Only the methods come over. The evidence run is under
agent-work/status/briefs/image-prep-samples/ (two typed pages of the Nicolasa Blandón file: the legacy
crop as before, the legacy enhanced file on the drive as reference, the method restated and re-run,
and today’s Enhance at auto-levels, contrast 1.3, sharpen 1.3; notes.json has the timings). The
restated method matches the reference to a mean of 1.4/255 — JPEG noise — so the parameters below are
the ones that made the owner’s “good result”.
| Tool | Input → output | Method and parameters | Keep |
|---|---|---|---|
crop.py |
photo → page crop + box, method, confidence | YOLOv8 page model first (best box, 30 px pad left/top/bottom); else contours: grey, optional blur, threshold by template (dark_background binary 120, light_background inverse 180, otsu, adaptive 11/2, edge Canny 50/150 + dilate), external contours, keep area 0.15–0.99 and aspect 0.5–2.0, largest, pad; else the original |
the templates and filter; YOLO optional |
remove_background.py |
photo → RGBA + crop | black coverage check; threshold 80; external contours; keep by size/centre; mask open 5, close 7, blur 21; crop to alpha | the mount mask as the dark-mount page finder |
enhance.py |
page → page | LAB; CLAHE on L (clip 2.2, tile 8×8, then ×1.1 −5 for “handwritten”; clip 1.6, tile 16×16 for “typescript”; the stroke-density heuristic calls nearly every page handwritten — both typed samples scored 8 %, threshold 0.5 %); yellow cast from mean b: subtract min(8, 3·cast), a ×0.98; unsharp 1.5/−0.5 at σ 3 | all of it, as one setting |
rotate.py |
page → page | Hough lines within ±2°, median, warpAffine white fill | today’s detect_deskew_angle is the same idea (±15°, longer lines) |
split.py |
spread → pages | aspect gate; darkest column in the middle 40 %; split only if darker than 180 and 15 darker than its neighbours; covers and labels by edges and filename | the aspect gate and the neighbour test |
segment.py |
page → line strips | deskew by three methods voted with a confidence (deskew_image: text baselines get_text_baseline_angle, Hough get_hough_angle, the largest contour’s minAreaRect; disagreement makes it conservative; ±2°; expand-and-crop so nothing is cut off); connected-component lines (get_connected_component_lines: Otsu inverse, external contours, boxes); safe cut points by row projection |
the baseline fit and the component lines feed the dewarp (prep.flatten.curve-toward-the-gutter); the three-method vote is the deskew’s confidence (prep.straighten-and-flatten); the Tesseract validate_rotation is not ported (no Tesseract in the app) |
prepare_images.py |
photo → JPEG | EXIF rotation, compression | nothing (media/upright.py) |
fuzzy_clean.py |
text → text | repeated-phrase and line cleaning of transcriptions, no pixels | nothing here (it is text, not image, despite the engine’s fuzzy_clean op sharing the name) |
enhance.DocumentAnalyzer |
page → document_type, is_yellowed |
type by Tesseract confidence, else stroke density (_morphological_heuristic: gradient of the Otsu binary, over 0.5 % = handwritten); yellowing from the LAB b mean (_detect_yellowing: (b − 128) / 30, clamped 0–1) |
the yellowing measure as the suggester for yellowing; the stroke-density heuristic as the suggester for contrast’s profile, with the honest note that it calls nearly every page handwritten; Tesseract is not ported |
What the legacy did not do, despite the memory of it: no stain flattening (CLAHE evens the lighting around a stain; the stain stays, as the reference shows), no ruled-line handling, no bleed-through handling, no blank or verso detection. Those are new below, model-free, and short.
What the legacy did wrong and the port does not: files written beside the originals with no link back; one folder layout and manifest per stage; crop before everything (so a loose YOLO box, as on sample 011, was never re-done); a “handwritten/typescript” switch that never switched; no preview, no undo, no per-page parameters.
The design
One function, three doors
Each step is one engine function media/enhance/<step>.py: run(image, params) -> (image, account)
with a typed params model. The same function is reached through three doors with the same parameters:
| Door | Where | Scope | Writes |
|---|---|---|---|
| Workflow node | workflows/tools/<step>.py, in the registry; a recipe’s Prepare Images step and the Start plan name it |
page, folder, project | a rendition per page via persist_workflow_renditions, with the run id |
| Editor operation | api/routes/ingest/image_editing.py operations/<step>; the editor’s tool, sliders from the params model |
one page (chain row), or a multi-selection (batch) | a chain row; the batch stamps its id on each row |
| Rendition | models.Rendition with role: "enhanced" and metadata.steps: [{step, params, method, confidence, run_id|batch_id|person}] |
the page | several renditions coexist; the viewer switches; a reader names which it read |
Rule of thumb: a parameter that exists in one door exists in all three, from one Pydantic model, so the OpenAPI client and the node config cannot drift.
The steps, in their usual order
The geometric steps (find and crop the page, square, flatten, rotate and deskew, split) are prep.*
in image-preparation.md; this spec keeps for them only that each is a rendition recorded with its method
and confidence. The pixel steps that follow run in this order when a recipe runs them all, and each is
also one control in the editor’s Adjust:
- Contrast (
contrast) · 2. Yellowing (yellowing) · 3. Stains (stain-flatten) · - Ruled lines (
ruled-lines) · 5. Show-through (bleed-through) · 6. Denoise (denoise) · 7. Sharpen (sharpen) · 8. Binarise (binarise, last, and only for a reader that wants it) · and, not pixels, 9. Show-through-only verso (verso-mark).
Each step: previewed on three sample pages (first, middle, last) before a folder runs; undone per page
(delete the row) or as a batch (batch-apply/{id}/undo); recorded on the rendition with its parameters.
A recipe’s Prepare Images step writes one enhanced rendition per page carrying the ordered steps,
not one rendition per step; the editor’s Adjust writes one chain row per applied step, and the chain
renders to the same pixels as the recipe would for the same values (image.enhance.three-doors-one-function).
The suite, step by step (owner 2026-10-29: the full OpenCV suite, ported not rewritten)
For every step: Source is the function the port starts from (the legacy tree
fichero_archive/_archive/fichero_legacy/tools/, or today’s engine), named so the builder copies its
method and parameters; Params are the one Pydantic model behind the three doors, with defaults;
Runs by itself says when the recipe applies it without being asked; Editor is its control in
Adjust (preview.edit.tool.adjust-previews-live-before-after): every control has an on/off switch, its
sliders, a live preview on the picture and the Before hold; Rendition is what it writes; Test is
its pinning check. “No legacy source” is said where the legacy had nothing, so nobody looks for it.
| Step | Source | Params (default) | Runs by itself | Editor | Rendition | Test |
|---|---|---|---|---|---|---|
Contrast contrast |
enhance.DocumentEnhancer.enhance, step 1: LAB, cv2.createCLAHE on L, then cv2.convertScaleAbs(l, 1.1, −5) for handwriting; the typescript branch is clip 1.6, tile 16, no gain |
clip 2.2 (1.0–4.0) · tile 8 (4–16) · gain 1.1 (0.8–1.4) · lift −5 (−20–20) · profile handwritten | typescript (handwritten; the typescript profile sets clip 1.6, tile 16, gain 1.0, lift 0; suggested by the stroke-density heuristic, which nearly always says handwritten, so the person’s answer wins) |
yes when Set Up’s answer is “stained or faded”, and for every page the bake-off shows it helps (prep.judged-by-reading); the engine’s one-click Auto Enhance is this step at its defaults |
Contrast: Strength (clip), Detail (tile), Gain, Lift; a Handwritten / Typescript switch | enhanced with steps[].step: contrast |
the two typed samples reproduce the reference enhanced files to a mean under 3/255 (image.enhance.contrast-clahe) |
Yellowing yellowing |
enhance.DocumentAnalyzer._detect_yellowing (cast = (mean b − 128) / 30, clamped 0–1) and DocumentEnhancer.enhance step 2: cv2.subtract(b, min(8, 3·cast)) when cast > 0.1, cv2.convertScaleAbs(a, 0.98, 0) |
strength auto (0–8, the subtraction in LAB b; auto = min(8, 3·cast)) · desaturate_a 0.98 (0.9–1.0) |
yes when the five-page sample’s mean cast is over 0.1 on 3 of 5 (the “stained or faded” answer); a page with cast under 0.1 writes nothing | Yellowing: Strength, with “Auto” ticked by default | the same enhanced rendition, steps[].step: yellowing |
a page tinted by a known b offset comes back within 2 of neutral in b; a neutral page is unchanged |
Stains stain-flatten |
today’s media/image_ops._flatten_illumination (PIL: MaxFilter 7 at 1/8 scale, GaussianBlur 4, divide, ×245) restated in OpenCV: cv2.morphologyEx(MORPH_CLOSE) on L with an elliptical kernel of long side / 40, blur, divide, rescale to the paper’s median; no legacy source (the legacy CLAHE evened the light around a stain and left the stain) |
kernel long side / 40 (1/80–1/20) · strength 1.0 (0–1, a blend with the input) · paper auto (the median of the top 20 % of L) |
yes when Set Up’s answer is “stained or faded”; the Fuzzy Clean op’s background_clean folds into it |
Stains: Strength, Size (kernel) | steps[].step: stain-flatten |
a painted stain’s mean is within 10/255 of clean paper after; the ink’s dark pixels (under the local background by more than the paper noise) are unchanged (image.enhance.stain-flattening) |
Ruled lines ruled-lines |
no legacy source; new: coloured ruling by an HSV hue mask (green, blue, red bands) kept where thin (under 3 px) and long (over 30 % of the page), filled with cv2.inpaint radius 3; grey ruling by cv2.HoughLinesP with the legacy rotate.hough_line_rotate parameters (threshold 100, min length 100, gap 10) kept where thin and horizontal |
colour auto | green | blue | red | none (auto) · grey off · max_width 3 px · min_length 0.3 · inpaint_radius 3 |
off until Set Up’s answer is “ruled or graph paper” AND the bake-off shows it helps this collection (ruling 2026-10-04: some models use the ruling as a guide) | Ruling: on/off, Colour, Strength (max width) | steps[].step: ruled-lines |
drawn green ruling over text is gone, the ink’s dark pixels unchanged, in test_enhance_stains_and_ruling.py (image.enhance.ruled-lines) |
Show-through bleed-through |
no legacy source; method A: after stains, an ink threshold on local contrast (cv2.adaptiveThreshold-style local mean minus k·local std, the Sauvola idea in OpenCV calls) pushes faint mirrored text to paper; method B when the verso is in the folder: mirror it, cv2.findTransformECC on the downsampled pair, subtract the registered copy scaled by a fitted gain |
method A | B (A; B offered when the leaf’s verso is present) · k 0.2 (0–0.5) · window long side / 60 · gain auto (B) · scope whole | regions (whole; see the show-through ruling below) |
yes when Set Up’s answer is “writing shows through” | Show-through: on/off, Strength (k), Method A/B, Whole page / By region | steps[].step: bleed-through with scope and, by region, the tile grid and gains |
the synthetic recto/verso pair: mirrored faint text under the paper noise, ink stroke width unchanged (image.enhance.bleed-through-suppression) |
Denoise denoise |
today’s media/image_ops.apply_fuzzy_clean (PIL MedianFilter 3 or 5) restated as cv2.medianBlur; specks by the legacy remove_background.BlackBackgroundRemoverMulti mask hygiene (cv2.morphologyEx open 5×5 then close 7×7) applied to the ink mask, so an isolated blob smaller than the open kernel goes and strokes stay |
radius 3 (3 | 5) · speck 5 px (0–9; 0 off) |
no; on when a person asks, and in the recipe only where the bake-off shows it helps (specks mislead a segmenter more than a reader) | Denoise: Radius, Specks | steps[].step: denoise |
a page with 1 % salt-and-pepper noise comes back with under 0.05 % and the ink’s component count unchanged |
Sharpen sharpen |
enhance.DocumentEnhancer.enhance step 3: cv2.GaussianBlur(σ 3) and cv2.addWeighted(1.5, −0.5) |
amount 0.5 (0–1.5) · radius 3 (1–6) |
yes, as the last pixel step of Contrast’s automatic run (the legacy did them together); never twice | Sharpen: Amount, Radius | steps[].step: sharpen |
the typed samples’ reference match counts it; a flat grey page is unchanged |
Binarise binarise |
crop.py’s threshold methods (otsu: cv2.threshold(THRESH_OTSU); adaptive: cv2.adaptiveThreshold(ADAPTIVE_THRESH_GAUSSIAN_C, 11, 2)), segment.get_connected_component_lines (Otsu inverse); today’s apply_operation("adaptive_binarize") is a global mean threshold wearing the adaptive name and is replaced |
method otsu | adaptive (adaptive) · block 31 (11–101, odd; the legacy 11 was for small images) · c 2 (−10–10) |
no; the recipe runs it only for a reader card whose sheet says it reads a binary image (Kraken’s own binarisation stays Kraken’s) | Binarise: on/off, Method, Block, Offset | steps[].step: binarise, a separate rendition role: binarised so the enhanced greyscale stays |
the Doc11 crop binarised by Otsu and by adaptive: the ink’s component count within 5 % of the greyscale’s; an old adaptive_binarize chain row re-renders to its old pixels |
| Mount removal (the dark-mount page finder) | remove_background.BlackBackgroundRemoverMulti.remove_background (threshold 80, skip under 1 % black, external contours, keep the largest and any over 20 % of it or near the centre, open 5 close 7, blur 21, alpha ×0.95, crop to alpha) |
owned by prep.page.opencv-default-yolo-optional and prep.page.remove-background-is-the-dark-mount-method |
yes, as the finder’s dark-mount method | Crop’s Find Page | the page crop; transparency flattens to white (media/image_flatten) |
test_find_page.py |
| Square and Flatten | crop.py’s contour polygon; segment.get_connected_component_lines and get_text_baseline_angle’s baseline fit |
owned by prep.square.* and prep.flatten.* |
yes | Crop’s corner handles; Adjust’s Flatten switch (on/off, Strength) | the squared and flattened renditions | test_square_page.py, test_flatten_curve.py |
| Deskew | rotate.hough_line_rotate (±2°, Hough 100/100/10, cv2.warpAffine white) and segment.deskew_image’s three-method vote; today’s detect_deskew_angle |
owned by prep.straighten-and-flatten |
yes | Straighten’s dial | the deskewed rendition | test_image_ops_legacy_port.py |
| Flip, Grayscale, Auto Levels | today’s parameterless ops | none | no | the panel’s Add Step menu | a chain row | existing test_*_images.py |
Not ported, and why: the Tesseract document-type test and validate_rotation (no Tesseract in the
app); the YOLO page weights yolov8s-fichero.pt as a dependency (a baseline to beat, prep.yolo.page-detector);
fuzzy_clean.py (text, not pixels); the rembg “AI” remover (remove_background.AIBackgroundRemover:
a portrait model, a 170 MB download, and worse than the threshold on a mount); the assets/<stage>/
folders and manifests (image.enhance.old-conventions-left-behind).
Show-through: one setting, then regions (owner 2026-10-29). The recipe fits method A’s k (and
method B’s gain) once for the folder from the five-page sample and applies it to every page. It
switches to per-region settings only when the sample is uneven: the sample pages are tiled 4×4, the
show-through contrast measured per tile (the mean local contrast of the mirrored-text mask), and when
the tiles’ coefficient of variation is over 0.5 on 3 of 5 pages the step fits k per tile, smoothed
across tile borders. The rendition’s row says which (scope: whole or scope: regions with the grid
and gains), the Set Up card says “show-through: one setting” or “by region, the sample is uneven”, and
the editor’s switch lets a person change it for a page.
Which door, which rendition. A recipe run writes one enhanced rendition per page with its ordered
steps and the run id; the editor writes chain rows and renders them; Apply to Selection writes a batch
of chain rows with the batch id. A reader names the rendition it read (image.enhance.rendition-records-its-steps).
Leave Out, after enhancement
Leave Out’s vocabulary gains ruler, colour card, show-through, show-through-only verso beside
too small, off the page, likely bleed-through. The page box comes from page-crop; the ruler and the
colour card are regions (the YOLO layout card’s labels, or the strips of the page box the finder
excluded) marked with their reason; the ink-contrast reading comes from the chosen enhancement
rendition when there is one; a show-through-only verso’s lines are all left out with the page-level
reason. Prep never marks; Leave Out never paints.
Reader prompt fragments
llm/prompt_fragments.py: named, versioned fragments, one wording per reader kind (line reader; page
VLM read: Gemini, Qwen-VL, local MLX), appended to the reader’s prompt when the recipe step names them;
the run record stores the ids and versions so results compare.
| Fragment | Line reader wording | Page reader wording |
|---|---|---|
ignore-show-through@1 |
“Faint, mirrored writing is the other side of the leaf showing through: do not read it.” | “Ignore faint mirrored text showing through from the other side of the leaf; transcribe only the writing on this side.” |
ignore-furniture@1 |
(already in PROMPT: ruler, graph paper, blank paper → null) |
“Ignore the ruler, the colour card, the mount and anything outside the page.” |
read-over-stamps@1 |
“A stamp or seal over the writing is not text: read the writing under and around it.” | “Where a stamp or seal lies over the text, read the text, not the stamp; name the stamp in one bracketed note.” |
A project can switch a fragment off; a new wording is a new version, never an edit in place.
The working rendition
The display image the import already makes (2200 px) becomes the working rendition: made once, keyed by content hash and size, reused by the canvas, Preview, the line reader’s page context, VLM page reads and cleanup previews, never regenerated by a run. Kraken and full-resolution steps read the original. If the source file is lost or its drive not mounted, the project still views, reads and re-runs whatever needs only the working rendition, and says plainly which step needs the original.
Measured
On two named sample pages (the owner’s: EAP1477_GHC_B16_Doc02_IMG_003.jpg, the hard one — stains, holes,
faint ink, green ruling, a revenue stamp; …IMG_069.jpg, blue ink, show-through, stamps; not on the
drive on 2026-10-26, the Fichero GHG/GHG folder is empty — to be supplied as fixtures) and the typed
pair already in the evidence folder: lines found, lines left out by reason, words read, show-through
words, seconds per page — before and after each step, and the 2×2 of prompt fragment × cleanup.
Behaviors
image.enhance.never-the-original— [OK] every step writes a rendition or a chain row; the source file’s bytes and checksum are unchanged. Pinned bytests/unit/workflows/test_image_prep_source_immutability.pyandpreview.edit.chain-is-a-separate-row-never-the-source.image.enhance.three-doors-one-function— [GAP] (#6194) each step is one function undermedia/enhance/reached by the workflow node, the editor operation and the batch with the same typed params; the editor request schema and the node config come from that one model.image.enhance.old-conventions-left-behind— [GAP] (#6194) the engine never imports fromfichero_archive, never reads or writes anassets/<stage>/folder or a stage manifest; a guardrail test names the forbidden import.image.enhance.rendition-records-its-steps— [PARTIAL] (#6191) an enhancement rendition carriesmetadata.steps(step, params, method, confidence, who: run id, batch id or person); the viewer switches between renditions; a reader records the rendition it read. Built:persist_workflow_renditionspairs outputs by source and copies bytes; the chain row holds the editor’s params. Missing: the card and run id on recipe-made renditions,role: enhanced, the prepared image attached as a rendition (#5386).image.enhance.page-crop— [PARTIAL] (#6188) finding the page isprep.find-the-pageandprep.page.opencv-default-yolo-optionalinimage-preparation.md; this spec keeps only that the crop is a rendition recorded with its method and confidence (image.enhance.rendition-records-its-steps). Built:split_pagescrops to Vision’s box. Missing: the crop as a recorded rendition.image.enhance.page-crop-writes-page-box— [GAP] (#6198) isprep.page.region-attribute-and-page-box; kept so #6198 resolves; its test is that line’s.image.enhance.rotate-deskew— [PARTIAL] (#6190) the one functionmedia/enhance/deskew.pybehind the node, the editor’s Straighten and the batch; the method and the angle on the row areprep.straighten-and-flatten. Built:rotate_images,deskew_images, the editor’s rotate and straighten. Missing: the one function.image.enhance.split-spread— [PARTIAL] (#5382) isprep.split-at-the-gutter; this spec adds only that the split’s parts are renditions and children recorded with the run id. Built:split_pagesandpersist_workflow_child_regions. Missing: the run id on the rows.image.enhance.contrast-clahe— [GAP] (#6190) thecontraststep is the legacyenhance.DocumentEnhancer.enhancestep 1 restated (LAB CLAHE clip 2.2 tile 8, L ×1.1 −5; the typescript profile clip 1.6 tile 16) withclip,tile,gain,lift,profileas params; withyellowingandsharpenat their defaults it reproduces the two typed samples’ reference enhanced files to a mean difference under 3/255.image.enhance.yellowing— [GAP] (#6190; owner 2026-10-29) theyellowingstep is the legacy_detect_yellowingmeasure and step 2 ofDocumentEnhancer.enhance(b minus min(8, 3·cast) when the cast is over 0.1, a ×0.98) withstrengthauto anddesaturate_a; runs by itself when the sample’s cast is over 0.1 on 3 of 5; a tinted fixture comes back within 2 of neutral in b and a neutral page is unchanged.image.enhance.sharpen— [GAP] (#6190) thesharpenstep is step 3 ofDocumentEnhancer.enhance(Gaussian σ 3, weights 1.5 and −0.5) withamountandradius; the recipe runs it once after contrast, never twice; a flat grey page is unchanged.image.enhance.editor-enhance-is-the-new-step— [GAP] (#6190) the editor’s Auto Enhance runscontrast,yellowing(auto) andsharpenat their defaults, and Adjust exposes every step of the suite with its sliders, on/off switch, live preview and Before (preview.edit.tool.adjust-previews-live-before-after); the PIL brightness/contrast/sharpness operation survives only aslegacy_enhanceso an existing chain re-renders to the same pixels; new chains never write it.image.enhance.every-step-is-a-recipe-step-and-an-editor-control— [GAP] (#6185; owner 2026-10-29) every pixel step in the suite table is reachable both as an automatic recipe step that writes a rendition and as an Adjust control in the editor, with the same params model and the same pixels for the same values; a guardrail test walks the params registry and asserts each step has a node, an editor operation and an Adjust control identifier.image.enhance.stain-flattening— [GAP] (#6195) background bycv2.morphologyExclosing (kernel long side / 40), divide and rescale to the paper’s median, the OpenCV form of today’s_flatten_illumination; a stain’s mean within 10/255 of clean paper after, ink contrast kept; the Fuzzy Clean op’sbackground_cleanfolds into it.image.enhance.ruled-lines— [GAP] (#6195) coloured ruling by hue mask (thin, long), grey ruling by long thincv2.HoughLinesPlines with the legacyhough_line_rotateparameters, filled bycv2.inpaintradius 3; the ink’s dark pixels unchanged; off by default, on from the onboarding answer and only where the bake-off shows it helps.image.enhance.bleed-through-suppression— [GAP] (#6196) method A (local-contrast ink threshold after stains,kandwindow) pushes mirrored faint text under the paper noise without thinning the ink; method B (mirrored verso registered bycv2.findTransformECCand subtracted with a fitted gain) offered when the verso is in the folder; previewed on three pages.image.enhance.show-through-one-setting-then-regions— [GAP] (#6196; owner 2026-10-29) the recipe fits the show-through setting once for the folder from the five-page sample and applies it to every page; it fits per 4×4 tile, smoothed, only when the sample’s tiles’ show-through contrast has a coefficient of variation over 0.5 on 3 of 5 pages; the rendition row and the Set Up card say which; the editor’s switch overrides it for a page. A uniform synthetic pair getsscope: whole; a pair whose verso is faint on one half and strong on the other getsscope: regions.image.enhance.denoise— [GAP] (#6185) thedenoisestep iscv2.medianBlur(radius 3 or 5, today’sapply_fuzzy_cleanrestated) plus speck removal by the legacy mask hygiene (open 5, close 7) on the ink mask; off unless asked or shown to help; 1 % salt-and-pepper noise falls under 0.05 % with the ink’s component count unchanged.image.enhance.binarise— [GAP] (#6185) thebinarisestep is Otsu or Gaussian adaptive thresholding fromcrop.py’s methods withblockandc, written as a separatebinarisedrendition; today’sadaptive_binarize(a global mean threshold) is replaced and an old chain row carrying it re-renders to its old pixels; the recipe runs it only for a reader whose card asks for a binary image.image.enhance.show-through-only-verso— [GAP] (#6197) a fixture pair (recto, mirrored faint verso) marks the secondshow-through-onlywith its recto; listed, dimmed, left out by default, one click to include, reversible, kept as method B’s input.image.enhance.leave-out-reads-the-page-box— [GAP] (#6198) Leave Out’s reasons gainruler,colour card,show-through,show-through-only verso; the page box and the ruler/card regions come from page-crop and the layout card;_ink_contrastreads the chosen enhancement rendition; prep never marks, Leave Out never paints.image.enhance.reader-prompt-fragment— [GAP] (#6199) versioned fragments per reader kind inllm/prompt_fragments.py; a step names them; the run stores ids and versions; a project switches one off; the line reader’s existing furniture sentence becomesignore-furniture@1.image.enhance.prompt-and-cleanup-measured-2x2— [GAP] (#6199) on three sample pages the engine reports show-through words per page for fragment on/off × cleanup on/off, and lines found, words read and seconds per page before and after each step (#6189 for the Kraken timing).image.enhance.batch-preview-undo— [PARTIAL] (#6192) a batch over a folder for every step of the suite, three sample pages before and after, one confirmation, batch undo and per-page undo. Built: crop/split batch with batch undo (#1265). Missing: the other steps, the preview, the per-page record.image.enhance.onboarding-step— [GAP] (#5580) isprep.setup.step-runs-from-the-start-planwithprep.setup.three-questions-suggested-from-a-sample; this spec adds only that the answers “stained or faded”, “ruled paper” and “writing shows through” switch oncontrast,ruled-linesandbleed-throughrespectively.image.enhance.working-rendition-once— [PARTIAL] (#6200, #6193) the stored working image is the SINGLE SOURCE for every reduced view of a page: rendered ONCE at import with shrink-on-load (Image.draftfor JPEG, the reduced read of a pyramidal TIFF, then one LANCZOS fit to 2200 px), keyed by content hash and size, the thumbnail cut from it, and read by the canvas, Preview, the line reader’s page context, VLM page reads and cleanup previews. NO RUN RENDERS IT AGAIN: a workflow, a read, a view or a re-import that finds the key present reads the file;ensure_thumbnail/ensure_displaywith the key present do no decode; Kraken and full-resolution steps read the original. Owner’s evidence (2026-10-26): a thumbnail at 1.6 s alone, about a minute under Kraken’s memory pressure, is a full decode of the TIF per request, not a reuse. Built:ensure_displaymakes a 2200 px file. Missing: the hash key (the cache is keyed by document id and path), shrink-on-load (_generate_imageopens the whole file), the thumbnail derived from the display (made separately today), the consumers that still re-render (line_reader._page_context_uriresizes the full page every batch), and the rule, pinned by a test, that a run never renders.image.enhance.reads-without-the-source— [GAP] (#6200) with the source unmounted, Preview, Read-a-page and a re-run that needs only the working rendition succeed; a step that needs the original says so and waits.
Test matrix
Fixtures (fichero-server/tests/fixtures/image_enhance/, to add): a reduced Doc11 page with ruler, colour
card and dark mount and its hand-drawn page box; the two GHC_B16 pages when supplied (IMG_003 hard,
IMG_069 show-through); a synthetic recto/verso pair (text, then its mirror at 25 % contrast); a page with
green ruling drawn over text; a page with a painted stain; a page tinted by a known LAB b offset; a page
with 1 % salt-and-pepper noise; a second recto/verso pair whose verso is faint on one half and strong on
the other; the two typed pages’ crops and their reference enhanced files.
| Behaviour | Unit | Click-around | Notes |
|---|---|---|---|
image.enhance.three-doors-one-function, image.enhance.old-conventions-left-behind |
tests/unit/media/test_enhance_three_doors.py |
not needed | Same params model in node config and editor request; forbidden import named. |
image.enhance.rendition-records-its-steps |
tests/unit/workflows/test_prepare_images_rendition_recipe.py |
needed | The viewer switches renditions; the reader names which it read. |
image.enhance.page-crop, image.enhance.page-crop-writes-page-box |
tests/unit/media/test_find_page.py, tests/unit/recipes/test_leave_out_reads_page_box.py |
needed | Outline within 2 % of the hand-drawn box; Doc11 ruler lines left out off the page. |
image.enhance.rotate-deskew, image.enhance.split-spread |
tests/unit/media/test_image_ops_legacy_port.py, tests/unit/media/test_page_split.py |
not needed | Angle on the row; the aspect gate. |
image.enhance.contrast-clahe, image.enhance.yellowing, image.enhance.sharpen, image.enhance.editor-enhance-is-the-new-step |
tests/unit/media/test_image_ops_legacy_port.py |
needed | Mean diff to the reference under 3/255; the tinted page neutral within 2; an old chain re-renders unchanged. |
image.enhance.every-step-is-a-recipe-step-and-an-editor-control |
tests/unit/media/test_enhance_suite_registry.py; ImageEditAdjustControlsTests.swift |
needed | Every step in the registry has a node, an editor op and an Adjust control; the same values give the same pixels through both. |
image.enhance.stain-flattening, image.enhance.ruled-lines |
tests/unit/media/test_enhance_stains_and_ruling.py |
needed | Stain within 10/255 of paper; ink pixels unchanged; ruling gone. |
image.enhance.bleed-through-suppression, image.enhance.show-through-one-setting-then-regions, image.enhance.show-through-only-verso |
tests/unit/media/test_enhance_bleed_through.py, tests/unit/media/test_show_through_only_verso.py |
needed | Synthetic pair; the uniform pair scope: whole, the uneven pair scope: regions; the verso marked, dimmed, included with one click. |
image.enhance.denoise, image.enhance.binarise |
tests/unit/media/test_enhance_denoise_binarise.py |
not needed | Noise under 0.05 % with components unchanged; Otsu and adaptive component counts within 5 %; an old adaptive_binarize row re-renders unchanged. |
image.enhance.leave-out-reads-the-page-box |
tests/unit/recipes/test_leave_out_reads_page_box.py |
not needed | Reasons vocabulary; contrast from the rendition; page-level verso reason. |
image.enhance.reader-prompt-fragment, image.enhance.prompt-and-cleanup-measured-2x2 |
tests/unit/llm/test_prompt_fragments.py |
not needed | Ids and versions on the run; four cells reported. |
image.enhance.batch-preview-undo, image.enhance.onboarding-step |
tests/unit/api/test_image_batch_prepare.py, tests/unit/recipes/test_start_plan_prepare_step.py; app ImageBatchPrepareTests.swift |
needed | Preview writes no row; the Start plan holds the step. |
image.enhance.working-rendition-once, image.enhance.reads-without-the-source |
tests/unit/db/test_working_rendition_once.py |
needed | Second call renders nothing; unmounted source still previews and reads. |
Build order
Stage 1 — The foundation and the page
Step 1.1 — One function, three doors (#6194)
- Behaviours:
image.enhance.three-doors-one-function,image.enhance.old-conventions-left-behind - Tests: fichero-server/tests/unit/media/test_enhance_three_doors.py
- Size: M
Step 1.2 — Renditions record their steps (#6191)
- Behaviours:
image.enhance.rendition-records-its-steps - Tests: fichero-server/tests/unit/workflows/test_prepare_images_rendition_recipe.py
- Depends on: 1.1
- Size: S
Step 1.3 — Page crop and the page box (#6188)
- Behaviours:
image.enhance.page-crop,image.enhance.page-crop-writes-page-box - Tests: fichero-server/tests/unit/media/test_find_page.py, fichero-server/tests/unit/recipes/test_leave_out_reads_page_box.py
- Depends on: 1.2
- Size: M
Step 1.4 — The working rendition, once (#6200)
- Behaviours:
image.enhance.working-rendition-once,image.enhance.reads-without-the-source - Tests: fichero-server/tests/unit/db/test_working_rendition_once.py
- Depends on: 1.1
- Size: M
Stage 2 — The ported methods
Step 2.1 — Contrast, yellowing and sharpen replace Enhance; rotate, deskew, split (#6190)
- Behaviours:
image.enhance.contrast-clahe,image.enhance.yellowing,image.enhance.sharpen,image.enhance.editor-enhance-is-the-new-step,image.enhance.rotate-deskew - Tests: fichero-server/tests/unit/media/test_image_ops_legacy_port.py, fichero-server/tests/unit/media/test_page_split.py
- Depends on: 1.2
- Size: M
Step 2.2 — Stains and ruled lines (#6195)
- Behaviours:
image.enhance.stain-flattening,image.enhance.ruled-lines,image.enhance.split-spread - Tests: fichero-server/tests/unit/media/test_enhance_stains_and_ruling.py, fichero-server/tests/unit/media/test_page_split.py
- Depends on: 2.1
- Size: M
Step 2.3 — Show-through, one setting then regions, and the show-through-only verso (#6196)
- Behaviours:
image.enhance.bleed-through-suppression,image.enhance.show-through-one-setting-then-regions,image.enhance.show-through-only-verso - Tests: fichero-server/tests/unit/media/test_enhance_bleed_through.py, fichero-server/tests/unit/media/test_show_through_only_verso.py
- Depends on: 2.2
- Size: M
Step 2.4 — Denoise and binarise; every step through both doors (#6185)
- Behaviours:
image.enhance.denoise,image.enhance.binarise,image.enhance.every-step-is-a-recipe-step-and-an-editor-control - Tests: fichero-server/tests/unit/media/test_enhance_denoise_binarise.py, fichero-server/tests/unit/media/test_enhance_suite_registry.py, fichero/Tests/Unit/general/Views/Preview/ImageEditAdjustControlsTests.swift
- Depends on: 2.3
- Size: M
Stage 3 — Around the reading
Step 3.1 — Leave Out reads what enhancement wrote (#6198)
- Behaviours:
image.enhance.leave-out-reads-the-page-box - Tests: fichero-server/tests/unit/recipes/test_leave_out_reads_page_box.py
- Depends on: 2.3
- Size: S
Step 3.2 — Prompt fragments and the 2×2 measure (#6199)
- Behaviours:
image.enhance.reader-prompt-fragment,image.enhance.prompt-and-cleanup-measured-2x2 - Tests: fichero-server/tests/unit/llm/test_prompt_fragments.py
- Depends on: 3.1
- Size: M
Step 3.3 — Batch with preview and undo; the onboarding step (#6192)
- Behaviours:
image.enhance.batch-preview-undo,image.enhance.onboarding-step - Tests: fichero-server/tests/unit/api/test_image_batch_prepare.py, fichero-server/tests/unit/recipes/test_start_plan_prepare_step.py
- Depends on: 3.2
- Size: M
Rulings
- 2026-10-26 (owner): two steps, prep (pixels, renditions) and Leave Out (decisions); order prep → find regions and lines → leave out → read. Port the methods and parameters, not the old app’s shape. The legacy “enhanced” look is the target. Both sides of a leaf photographed: a show-through-only verso is marked and left out, never deleted. The working rendition is made once at import.
- 2026-10-29 (owner, paraphrased): the whole OpenCV suite comes over, not levels alone: CLAHE contrast,
yellowing, stains, show-through, ruled lines, binarisation, denoise and despeckle, sharpening, mount
removal and the gutter dewarp; each is an automatic recipe step that writes a rendition AND a control in
Preview’s editor with sliders and before/after; the port names its source function and does not
rewrite. Show-through is one setting fitted from the sample, by region only when the sample is uneven.
Perspective correction and the dewarp are in now, owned by
image-preparation.md.
Open questions
None. Method B’s gain (the former question 1) is ruled: one gain from the sample, per tile only when the
sample is uneven (image.enhance.show-through-one-setting-then-regions); perspective moved to
image-preparation.md and is ruled there.
(Not a decision: the two GHC_B16 sample pages are not on the drive (Fichero GHG/GHG was empty on 2026-10-26); the measure in image.enhance.prompt-and-cleanup-measured-2x2 waits on them as fixtures.)