diff --git a/hub/public/office-sample-assets/atlas.png b/hub/public/office-sample-assets/atlas.png new file mode 100644 index 0000000..90ca5fd Binary files /dev/null and b/hub/public/office-sample-assets/atlas.png differ diff --git a/hub/public/office-sample-assets/frames.json b/hub/public/office-sample-assets/frames.json new file mode 100644 index 0000000..95ecdd2 --- /dev/null +++ b/hub/public/office-sample-assets/frames.json @@ -0,0 +1,122 @@ +{ + "_comment": "Frame table for the t-59 office sample, round 28 \u2014 moneta's round-27 verdict held craft gap open (mechanical floor from round 27 confirmed real and fixed; face/hair/shading craft gap confirmed still open, deliberately untouched by round 27). Round 28 attempts that craft gap for the first time by ROOT-CAUSING it rather than re-tuning a crop: raw cell-vote inspection (pre-palette-snap) showed a genuine multi-tone skin ramp and hair variation already present in the source sheet's votes \u2014 the flat blue-grey face every round since 14 saw was build_shared_palette's own frequency-sorted palette_size=28 truncation discarding every warm/skin-toned bucket before the cut, because skin/hair occupy far less total cell area across the 5-frame shared-palette group than the navy suit + cool background + monitor bezel that dominate it \u2014 not an inherent scale limit, exactly moneta's own round-26 23:54 hypothesis. Raising palette_size (28->128, empirically chosen: warm-tone recovery diminishes sharply past 128) recovers the ramp, but doing so surfaced a SECOND, previously-latent bug: a handful of dark-violet opaque interior votes (e.g. RGB(54,2,56)) that clear build_shared_palette's existing Euclidean bg-distance safety net by a wide margin but still carry the chroma key's own hue signature \u2014 the exact defect class round 27 fixed for the edge band (magenta_hue_score) but never threaded into the palette path, because a palette_size=28 was small enough that these low-vote-count buckets never survived the frequency cut in any prior round, so the exposure never existed before. Fixed at the tool level in the same function that already carries the Euclidean net (build_shared_palette), not by shrinking palette_size back down. Every sprite below is produced by hub/tools/rectify.py, never hand-despeckled/quantized. Re-run any manifest in rectify-manifests/ against the cited region and diff the output PNG's sha256 against the matching rectify-manifests/report-*.json (recorded when this atlas was built) to verify determinism.", + "round27Fixes": { + "chromaKeyHueFringe": "Root cause was in rectify.py's decontamination math itself, not a region/crop choice (unlike every round26Fixes entry): cell_vote_grid's edge-band formula (observed = alpha_frac*fg + (1-alpha_frac)*bg, solved for fg) uses alpha_frac derived from a single scalar - the cell's Euclidean color-distance to the background - as a stand-in for the true blend fraction. That's an approximation, and on a handful of isolated edge cells it understated the real alpha enough that solving for fg left a residual magenta tint even after 'decontamination' - exactly what moneta's round-26 hue scan (min(R,B)-G, not Euclidean distance) caught on typing-1 (local (32,5), (18,14)) and typing-2 (local (16,21)). Independently re-derived all three from the committed atlas myself before touching anything (byte-identical to moneta's report) and ran a full-atlas hue scan across all 10 shipped sprites (not just the flagged two) - found ONE further undisclosed instance she hadn't flagged: typing-3 local (0,27) = RGB(134,51,113) alpha 184, hue score 62, same signature. Fixed at the TOOL level, not per-pixel: added magenta_hue_score() and a new edge_hue_tolerance manifest parameter (default 40, threaded through cell_vote_grid) - after decontamination, any edge cell whose fg color still scores above the tolerance is dropped to fully transparent instead of shipped tinted (the neighboring already-safe cell one step in carries the sprite's real edge, so nothing visible is lost - verified below). Chose 40 empirically: scanned every opaque/edge pixel in the committed atlas, every legitimate one scores <=28 (including the already-disclosed, already-accepted chair pixel at (15,15)=(22,1,23), score 21 - correctly left untouched, it is an opaque interior vote at 255 alpha, not an edge-band cell, and scores well under the bound either way), the 4 defects score 62-92 - a wide, clean margin. Re-ran the typing/idle manifest end-to-end with the fix: exactly 4 pixels changed atlas-wide (the 3 disclosed + the 1 self-found), all 4 dropped cleanly to alpha 0, zero other pixels touched (verified by a full old-vs-new atlas diff, not spot-checked). Re-ran the desk, tiles, and window manifests too (the fix is global, in cell_vote_grid) - byte-identical to the committed reports on every sprite, confirming the new safety net only ever fires on genuine key-hue residue, not on legitimate content elsewhere in this atlas. 10x-zoom crops of all 4 fixed coordinates (before/after) confirm no visible hole or discontinuity - the fringe simply disappears into the surrounding tone.", + "faceHairShading": "NOT attempted this round - see moneta's own round-26 note and round24Fixes.faceFidelity below; still the single biggest remaining gap, unchanged. This round's scope was the newly-disclosed mechanical floor violation only, per the loop's own rule that a mechanical floor failure is an automatic send-back with no taste judgment involved and takes priority over craft work in the round that finds it." + }, + "round26Fixes": { + "chair": "Root cause was the SOURCE REGION, not a rectifier bug: round 24/25's chair region (props/sheet-desks-workstations-v2.jpg, x515 y513 w180 h185) sampled the SIDE-VIEW chair in row 3 of the sheet \u2014 a thin, diagonal, low-contrast silhouette that a 9.27px/output-px pitch (this manifest's own, fixed by the sheet's anchor figure) mode-votes down into an indistinct ~19x19 blob, which is what read as 'melds into the desk leg.' Switched to the FRONT-FACING chair at the start of the same row (x55 y519 w159 h239, tight-cropped to its own connected component so no neighbor chair or divider border bleeds in) \u2014 a wide, symmetrical, high-contrast silhouette (backrest / seat / armrests / 5-star wheelbase) that survives the same mode-vote as a legible chair icon at 17x25. The old region's report also carried faint dark-magenta-hued palette entries ((21,2,25), (38,0,39), (70,2,70) \u2014 R\u2248B, G\u22480, i.e. a hue signature even though their Euclidean distance to the bg color cleared outer_tol so the existing safety filter didn't catch them); the new region's own palette was checked the same way (scanned every opaque pixel for R\u2248B>>G at ANY brightness, not just the bg-distance filter) \u2014 one residual 1px hit at (15,15)=(22,1,23), alpha 255, sitting exactly on the silhouette's own right edge next to the outline color, not a repeat of the old blotch pattern. Disclosing rather than hiding: invisible at the rendered scale (verified by direct 10x-zoom crop, no visible fringe), but a genuine finding for whoever tunes the mode-vote/decontamination boundary next.", + "window": "Root cause was the SOURCE REGION again: round 20-24's window crop (x215 y435 w273 h172) clipped both the top AND bottom frame rails of the window (the tight connected-component bbox of the same BLUE NIGHT cell is x207-464 y420-639, i.e. the shipped crop started 15px too low and ended 32px too early) \u2014 cutting the two strongest, highest-contrast structural lines in the source object is exactly what left 'a noisy dark-teal rectangle' once the venetian-blind texture (much lower contrast, much finer-pitched than the frame) was all that remained to mode-vote. Re-cropped to the full tight bbox (207,420,257,219): the outer frame border and the center mullion between the two panes both now survive the mode-vote as visibly darker, continuous lines around/through the sprite (36x31 vs the old 38x24 \u2014 taller because the bottom rail is included now), confirmed by direct pixel sampling at the frame/interior boundary, not just visual impression.", + "bridging": "Both furniture fixes above are pixel-level; this one is composition-level, in office-sample.html's layout(), not the rectifier. Round 24 placed the second desk+chair cluster at x178 (chair at x186) against a canvas that runs to x240, while the character (with its own integrated desk, visible only in the typing states) sits at x84 \u2014 an 84px gap of bare floor between the two furniture groups, which is what read as 'two disconnected pieces of furniture in a mostly-empty room.' Pulled the second desk+chair in to x128/x168, immediately past the character's own desk footprint (character sprites run up to x~123 at their widest) with a small clearance gap, not touching. The whole furniture run (window x18-54, character+desk x84-123, second desk+chair x128-185) now reads as one continuous wall-hugging cluster with a single open-floor margin on the right (x185-240) rather than furniture stranded on both ends of an empty canvas.", + "bridgingBaselineFollowup": "An inner-critic sub-agent pass (run before this round was first parked) closed the X-axis gap claim but found a real Y-axis one it exposed: measured directly (not eyeballed) that every sprite in this scene carries its own 'feet'/base on its own bottom-most pixel row (checked typing-0, desk-tidy, chair \u2014 all three have opaque content all the way to their sprite's last row, no baked-in ground margin), so the shared floor plane in this scene is 'sprite bottom edge sits near FLOOR_Y (70).' The character's own bottom (y=25+48=73) sits 3px past FLOOR_Y \u2014 the grounded reference. desk-tidy/chair were bottoming out at 94/97 (21-24px further into the floor than the character), which is what actually read as 'two different depths shoved together' once the X-gap closed. Fixed by moving both up so their own bottoms land close to the same line: desk-tidy now bottoms at 74 (y=54), chair at 82 (y=57, a few px further 'forward' than the desk). Re-verified visually post-fix: the whole cluster now sits on one shared floor line." + }, + "round24Fixes": { + "chromaKeyFringe": "rectify.py's chroma-key was a hard single threshold: a cell was either fully background or fully foreground with its raw voted color, which is wrong for real anti-aliased edge pixels (genuine blends of foreground and background). Rebuilt as a soft two-band key: alpha ramps linearly across an inner/outer distance band, and edge-band colors are DECONTAMINATED (observed = alpha_frac*fg + (1-alpha_frac)*bg, solved for fg) instead of shipped as-is. Also found and fixed a second, subtler bug this surfaced: the shared-palette builder's bucket-averaging step (a convex combination of individually-safe votes) could itself drift back into key-adjacent territory and get handed out by snap_to_palette as if it were a safe color \u2014 this is what caused round 23's recurring magenta-tinted blotches even after raising key_tolerance. Fixed with an explicit safety filter: any palette candidate (base or accent) whose own distance to the background color falls under the outer tolerance is rejected outright, never becomes a snap target. Verified: a distance-to-background scan of every opaque and semi-opaque pixel in every round-24 sprite (idle, all 4 typing frames, wall tile, window, desk, chair) finds zero pixels within key_tolerance of the background color \u2014 zero, not 'fewer'.", + "wallSpeckles": "Same root cause as the fringe bug (the wall tile's own edge pixels), fixed by the same soft-key + palette-safety-filter change. Re-verified directly: zero key-adjacent pixels in the isolated wall-cracked.png swatch, so tiling it repeatedly across the wall band no longer multiplies stray specks.", + "windowComposition": "Dropped the disconnected 'floor light-pool' half of the window crop \u2014 see round26Fixes.window for the region fix that superseded this round's crop coordinates.", + "secondDeskComposition": "Added a chair (props/sheet-desks-workstations-v2.jpg, row 3) positioned in front of desk-tidy \u2014 see round26Fixes.chair for the region fix that superseded this round's chair coordinates.", + "faceFidelity": "Checked, not fixed \u2014 and explained why it's not a defect to fix. The character sprite is 20px wide at the mandated 48px-tall canonical scale (pitch is fixed by the sheet's own anchor-figure measurement, never a free parameter \u2014 'the grid pitch is the scaling knob' per PROMPTS.md's Rectification section). At that pitch the face region is roughly 10 source-cell-widths, so facial detail resolves into a handful of blocky mode-voted cells by construction \u2014 this is the same order of coarseness any 48px-tall pixel-art character's face carries (compare any Sea of Stars party-member sprite at native scale), not a phase-alignment or vote defect specific to this pipeline. Still open, unchanged this round \u2014 no sheet-scale or scene-scale change was made." + }, + "atlas": "atlas.png", + "rectifyManifests": "rectify-manifests/ (one JSON per sheet; pitch is derived per-sheet from that sheet's own 48px anchor figure, never guessed or shared across sheets)", + "sourceSheets": { + "idle-0": "cast/sheet-agent-undercut-typing.jpg, column 0 (the anchor figure) of the 5x2 grid, row 1. HONEST FLAG (unchanged from round 21-25): this is the standing-away-from-desk pose reused as 'idle', not a true seated-idle pose \u2014 no sheet in the current set shows the character idle AT the desk without typing (kit prompt 28, 'seated idle', is still open per PROMPTS.md's NEXT WINDOW list).", + "typing-0..3": "cast/sheet-agent-undercut-typing.jpg, row 1, columns 1-4 of the same 5x2 grid \u2014 the character's own integrated desk/monitor/lamp, one continuous piece of source art, same light register as idle-0 by construction (same sheet, same row). Unchanged this round.", + "floor-wood": "props/sheet-tiles-surfaces-v2.jpg, row 1 column 1 (first wood-plank tile). Unchanged this round.", + "wall-cracked": "props/sheet-tiles-surfaces-v2.jpg, row 1 column 4 (cracked plaster wall texture) \u2014 cool spy-HQ register. Unchanged this round.", + "desk-tidy": "props/sheet-desks-workstations-v2.jpg, row 2 column 3 (a plain desk already dressed with a folder + keyboard in the source art) \u2014 a SEPARATE desk from the character's own seat, not a merge onto it (see layoutNote). Unchanged this round.", + "chair": "props/sheet-desks-workstations-v2.jpg, row 3 \u2014 round 26: switched from the side-view chair (x515 y513) to the front-facing chair at the start of the same row (x55 y519 w159 h239, tight-cropped to its own connected component). See round26Fixes.chair.", + "window-blue-night": "scenes/scene-window-states.jpg, row 2 column 1, labeled BLUE NIGHT \u2014 round 26: re-cropped to the full tight bbox (x207 y420 w257 h219) so both frame rails (top and bottom) and the center mullion survive the mode-vote. See round26Fixes.window." + }, + "layoutNote": "The mission's re-scope names sheet-desks-workstations-v2 as the desk source, but the typing sheet's character is already drawn integrated with its own desk/monitor/lamp (one inseparable piece of source art). Rather than attempt a pixel-level merge of two different sheets' desks under one seated figure \u2014 the two-light-sources mechanical-floor violation that sent round 20 back \u2014 this sample sources BOTH: the character keeps its own integrated desk (typing-0..3), and a second, separately-extracted desk (now with a chair) sits close beside it in the corner as additional furniture, per round26Fixes.bridging. Flagged as an open interpretation call, not assumed silently; a future round can revisit if the boss reads the mission text more literally.", + "droppedThisRound": { + "standalone-folder-crop": "Attempted (round 23) a second, standalone folder crop from props/sheet-desk-supplies-v2.jpg \u2014 came back as a near-solid tan block, a bad region fit. Dropped; desk-tidy's own built-in folder covers the 'dressed desk' ask on its own." + }, + "states": { + "idle": { + "frames": [ + "idle-0" + ], + "durationMs": 650 + }, + "typing": { + "frames": [ + "typing-0", + "typing-1", + "typing-2", + "typing-3" + ], + "durationMs": 220 + } + }, + "sprites": { + "idle-0": { + "x": 2, + "y": 2, + "w": 20, + "h": 48 + }, + "typing-0": { + "x": 24, + "y": 2, + "w": 38, + "h": 48 + }, + "typing-1": { + "x": 64, + "y": 2, + "w": 39, + "h": 48 + }, + "typing-2": { + "x": 105, + "y": 2, + "w": 37, + "h": 48 + }, + "typing-3": { + "x": 144, + "y": 2, + "w": 38, + "h": 48 + }, + "floor-wood": { + "x": 184, + "y": 2, + "w": 55, + "h": 45 + }, + "wall-cracked": { + "x": 241, + "y": 2, + "w": 52, + "h": 45 + }, + "desk-tidy": { + "x": 295, + "y": 2, + "w": 37, + "h": 20 + }, + "chair": { + "x": 334, + "y": 2, + "w": 17, + "h": 25 + }, + "window-blue-night": { + "x": 353, + "y": 2, + "w": 36, + "h": 31 + } + }, + "round28Fixes": { + "faceHairShadingPaletteStarvation": "ROOT CAUSE (confirmed by direct evidence, not inferred): dumped cell_vote_grid's raw per-cell mode-vote colors for typing-0's head region BEFORE any palette snap. Found a genuine shaded skin ramp already present in the source: highlight ~RGB(229,151,123), mid ~RGB(218,153,136)/(167,102,90), shadow ~RGB(102,55,56)/(40,4,1) \u2014 a real 4+ tone ramp, not a flat color. The shipped round-27 atlas's face was flat blue-grey anyway because build_shared_palette's `base = base[:palette_size]` truncation (palette_size was 28, shared across the whole 5-frame character group: idle-anchor + typing-0..3) is a pure frequency sort \u2014 skin/hair's small on-screen area next to the navy suit/cool background/monitor bezel that dominate cell-count across all 5 frames means every skin/hair bucket ranked below the top-28 cut and got discarded before ANY pixel could snap to it. accent_max_area_frac's small-rare-saturated-color exemption (round 25's fix) does not rescue this: measured skin saturation ~0.38-0.46, under the 0.5 accent_saturation_min floor, so skin was never accent-eligible by design, and shouldn't be \u2014 the real defect is base-palette starvation, not a missing accent case.", + "fix": "manifest-typing4.json: palette_size 28 -> 128 (see the manifest's own _comment_palette_size for the full empirical justification: warm/skin candidates go 0/1/2/6/12/12/13 at 28/48/64/96/128/160/200 \u2014 diminishing returns clearly set in by 128). This ONLY changes how many base (already outer_tol-cleared, non-accent) palette buckets survive the frequency cut; it does not touch accent_max_area_frac (left at round 25's 0, untouched) or cell_vote_grid/chroma-key logic at all.", + "secondBugFoundAndFixed": "Raising palette_size let several dark-violet opaque interior-vote buckets (e.g. (54,2,56) hue score 52, (56,2,74) hue score 54) survive the frequency cut for the first time \u2014 they clear build_shared_palette's existing Euclidean bg-distance safety net by a wide margin (dist ~283 vs outer_tol 170) but carry the chroma key's own hue signature, the same defect class round 27's magenta_hue_score fixed for cell_vote_grid's edge band but never threaded into the palette-candidate path (no prior round's palette_size was big enough to expose it). Fixed in rectify.py: build_shared_palette now takes edge_hue_tol and rejects any candidate whose magenta_hue_score exceeds it, same as the edge band. Re-ran with the fix: 16 candidates rejected on the character group (a mix of near-key and hue-tainted), full-atlas post-fix scan (hue>40 AND euclidean<170, opaque cells only) = 0 hits on all 5 character sprites and the full composited atlas.", + "verification": "Determinism: manifest run twice, byte-identical sha256 on all 5 sprites both times. Source sheet: sha256 3d20b2e3a35577ec624293ded91e123bd7120e7f2d3587ea09b5b7cc8c958521, matches report-typing.json's own previously-committed sheet_sha256 exactly (same source bytes as every prior round). Chroma-key cleanliness: full-atlas scan (not spot-checked) for opaque pixels scoring hue>40 or euclidean-distance-to-key<170 = 0 hits, both before AND after compositing into atlas.png. Non-character sprites (desk-tidy, chair, floor-wood, wall-cracked, window-blue-night): re-ran their manifests too (the rectify.py change is global, in build_shared_palette) \u2014 all 5 byte-identical (sha256-matched) to the committed report-*.json, confirming the new hue check only fires on genuine key-hue residue and doesn't touch legitimate content elsewhere in the atlas. Atlas diff (old vs new, full pixel-for-pixel): exactly 1892 pixels changed, entirely inside the 5 character sprite regions (idle-0: 239px, typing-0..3: 421/418/410/404px) \u2014 0 pixels changed anywhere else (floor-wood, wall-cracked, desk-tidy, chair, window-blue-night all report 0 changed pixels in their own atlas region).", + "stillOpen": "Reference still very likely wins on precision, disclosed honestly, not self-declared closed: side-by-side at matched native-canvas zoom against crops/crop-face-expression-zale.png and crops/crop-hair-silhouette-brugaves.png shows the reference's crisp individual 2px eye dots and sharply separated hair highlight/shadow bands read as clean, deliberate graphic marks; this round's result is a real, multi-tone shaded face and a hair mass with genuine highlight variation, but reads softer/more painterly and does not resolve a distinct two-eye read at this native pixel count. This is the single biggest remaining gap for the critic's own judgment, not assumed away.", + "routingNoteAddedAfterPush": "Added after this round's commit landed, once an independent inner-critic pass flagged it was missing: mission t-59 was discarded/tombstoned by the boss's re-scope at 2026-08-17T00:14Z, ~13 minutes BEFORE this round's commit (0298b16) was pushed \u2014 replaced by a staged pipeline (t-141 rectified sprites, t-143 composition, t-144 animation). This branch/PR #12 remains the designated code vehicle carrying rounds 1-28 forward into that pipeline per the re-scope note itself, so this work is not wasted, but it targets a task already closed when it was written. A second, concurrent bettik-named session independently found the identical palette-starvation root cause and delivered a DIFFERENT fix (keeps accent_max_area_frac=0, restores nothing there; adds a freshly-calibrated palette_hue_tolerance=25 rather than reusing round 27's edge_hue_tol=40 default) directly to t-141's holder (kassad) via message m-153, minutes before this round's push. Two independent, verified fixes for the same bug now exist on top of kassad's round-27 commit 3071ab5 \u2014 see t-59's mission log and message m-157 for the cross-reference. Whoever integrates t-141 should diff both rather than assume this round's is the only (or the chosen) one." + } +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/manifest-desk.json b/hub/public/office-sample-assets/rectify-manifests/manifest-desk.json new file mode 100644 index 0000000..bf20b3e --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/manifest-desk.json @@ -0,0 +1,39 @@ +{ + "sheet": "props/sheet-desks-workstations-v2.jpg", + "background_key": [ + 255, + 0, + 255 + ], + "key_tolerance": 170, + "anchor_region": [ + 45, + 10, + 175, + 455 + ], + "anchor_logical_height": 48, + "palette_size": 24, + "accent_saturation_min": 0.5, + "accent_max_area_frac": 0, + "regions": [ + { + "name": "desk-tidy", + "x": 1035, + "y": 254, + "w": 350, + "h": 201, + "group": "desk" + }, + { + "name": "chair", + "x": 55, + "y": 519, + "w": 159, + "h": 239, + "group": "chair", + "_round26": "Switched from the side-view chair (x515 y513, row 3) to the front-facing chair at the start of the same row, tight-cropped to its own connected component. See frames.json's round26Fixes.chair for why." + } + ], + "_comment": "Sheet path is relative to projects/bureau/references/office/ in the brain (GET /api/knowledge?file=projects/bureau/references/office/&raw=1) \u2014 download it there first, the reference sheets are not committed to git. Re-run: python3 hub/tools/rectify.py --out-dir /tmp/check (after adjusting 'sheet' to your local download path) and diff the sha256 in the matching report-*.json against this atlas's own sprite hashes." +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/manifest-tiles.json b/hub/public/office-sample-assets/rectify-manifests/manifest-tiles.json new file mode 100644 index 0000000..74f2ffa --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/manifest-tiles.json @@ -0,0 +1,38 @@ +{ + "sheet": "props/sheet-tiles-surfaces-v2.jpg", + "background_key": [ + 255, + 0, + 255 + ], + "key_tolerance": 170, + "anchor_region": [ + 50, + 10, + 165, + 240 + ], + "anchor_logical_height": 48, + "palette_size": 32, + "accent_saturation_min": 0.15, + "accent_max_area_frac": 0, + "regions": [ + { + "name": "floor-wood", + "x": 270, + "y": 20, + "w": 265, + "h": 220, + "group": "floor" + }, + { + "name": "wall-cracked", + "x": 1130, + "y": 20, + "w": 255, + "h": 220, + "group": "wall" + } + ], + "_comment": "Sheet path is relative to projects/bureau/references/office/ in the brain (GET /api/knowledge?file=projects/bureau/references/office/&raw=1) \u2014 download it there first, the reference sheets are not committed to git. Re-run: python3 hub/tools/rectify.py --out-dir /tmp/check (after adjusting 'sheet' to your local download path) and diff the sha256 in the matching report-*.json against this atlas's own sprite hashes." +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/manifest-typing4.json b/hub/public/office-sample-assets/rectify-manifests/manifest-typing4.json new file mode 100644 index 0000000..bde8836 --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/manifest-typing4.json @@ -0,0 +1,63 @@ +{ + "sheet": "cast/sheet-agent-undercut-typing.jpg", + "background_key": [ + 255, + 0, + 255 + ], + "key_tolerance": 170, + "anchor_region": [ + 24, + 22, + 150, + 362 + ], + "anchor_logical_height": 48, + "palette_size": 128, + "_comment_palette_size": "Round 28: raised from 28. Root cause of the round-14..27 face/hair craft gap (moneta's round-26 23:54 lead) was NOT an inherent scale limit — raw cell-vote inspection (pre-palette-snap) showed a genuine multi-tone skin ramp (highlight ~(229,151,123) to deep shadow ~(40,4,1)) and hair variation present in the source votes. build_shared_palette's frequency-sorted 'base = base[:palette_size]' truncation was dropping every warm/skin bucket before the top-28 cut: skin/hair occupy a small fraction of total cell area next to the navy suit + cool background + monitor bezel that dominate all 5 frames in this shared-palette group, so their buckets always ranked below the cut regardless of how visually distinct they are. This is NOT the accent_max_area_frac=0 exemption's job (round 25's fix, left untouched here) — skin saturation measured ~0.38-0.46, under accent_saturation_min's 0.5 floor, so it was never accent-eligible and was never meant to be; the real fix is giving the base palette enough room to keep small-area-but-distinct material ramps instead of starving them. 128 chosen empirically: warm/skin candidates in the resulting palette go 0 (at 28) -> 1 (48) -> 2 (64) -> 6 (96) -> 12 (128) -> 12 (160) -> 13 (200) — diminishing returns clearly set in by 128, so it captures the real ramp without needlessly ballooning past the point of new information. Verified: does not touch accent_max_area_frac, does not touch cell_vote_grid/chroma-key logic at all — palette_size only changes how many BASE (already-outer_tol-cleared, non-accent) buckets survive truncation.", + "accent_saturation_min": 0.5, + "accent_max_area_frac": 0, + "regions": [ + { + "name": "idle-anchor", + "x": 24, + "y": 22, + "w": 150, + "h": 362, + "group": "undercut-agent" + }, + { + "name": "typing-0", + "x": 190, + "y": 22, + "w": 290, + "h": 362, + "group": "undercut-agent" + }, + { + "name": "typing-1", + "x": 496, + "y": 22, + "w": 292, + "h": 362, + "group": "undercut-agent" + }, + { + "name": "typing-2", + "x": 804, + "y": 22, + "w": 285, + "h": 362, + "group": "undercut-agent" + }, + { + "name": "typing-3", + "x": 1105, + "y": 22, + "w": 284, + "h": 362, + "group": "undercut-agent" + } + ], + "_comment": "Sheet path is relative to projects/bureau/references/office/ in the brain (GET /api/knowledge?file=projects/bureau/references/office/&raw=1) \u2014 download it there first, the reference sheets are not committed to git. Re-run: python3 hub/tools/rectify.py --out-dir /tmp/check (after adjusting 'sheet' to your local download path) and diff the sha256 in the matching report-*.json against this atlas's own sprite hashes." +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/manifest-window.json b/hub/public/office-sample-assets/rectify-manifests/manifest-window.json new file mode 100644 index 0000000..a255d0c --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/manifest-window.json @@ -0,0 +1,32 @@ +{ + "sheet": "scenes/scene-window-states.jpg", + "background_key": [ + 255, + 0, + 255 + ], + "key_tolerance": 170, + "anchor_region": [ + 0, + 345, + 210, + 423 + ], + "anchor_logical_height": 48, + "palette_size": 24, + "accent_saturation_min": 0.5, + "accent_max_area_frac": 0, + "regions": [ + { + "name": "window-blue-night", + "x": 207, + "y": 420, + "w": 257, + "h": 219, + "group": "window", + "_round26": "Re-cropped to the BLUE NIGHT cell's full tight connected-component bbox (was x215 y435 w273 h172, which clipped both the top and bottom frame rails). See frames.json's round26Fixes.window for why." + } + ], + "anchor_source_px_height": 335, + "_comment": "Sheet path is relative to projects/bureau/references/office/ in the brain (GET /api/knowledge?file=projects/bureau/references/office/&raw=1) \u2014 download it there first, the reference sheets are not committed to git. Re-run: python3 hub/tools/rectify.py --out-dir /tmp/check (after adjusting 'sheet' to your local download path) and diff the sha256 in the matching report-*.json against this atlas's own sprite hashes." +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/report-desk.json b/hub/public/office-sample-assets/rectify-manifests/report-desk.json new file mode 100644 index 0000000..350dacc --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/report-desk.json @@ -0,0 +1,257 @@ +{ + "sheet": "props/sheet-desks-workstations-v2.jpg", + "sheet_sha256": "2352253efa470e4aa8c681af75270bc094198298a0037d0c861f4c2e92e1885b", + "anchor_source_px_height": 445, + "anchor_logical_height": 48, + "pitch": 9.270833333333334, + "groups": { + "desk": { + "palette": [ + [ + 2, + 2, + 7 + ], + [ + 89, + 121, + 131 + ], + [ + 90, + 122, + 132 + ], + [ + 22, + 28, + 41 + ], + [ + 3, + 4, + 15 + ], + [ + 34, + 51, + 66 + ], + [ + 88, + 118, + 129 + ], + [ + 202, + 174, + 113 + ], + [ + 109, + 148, + 153 + ], + [ + 110, + 149, + 157 + ], + [ + 25, + 39, + 54 + ], + [ + 38, + 54, + 69 + ], + [ + 22, + 38, + 54 + ], + [ + 24, + 29, + 42 + ], + [ + 66, + 90, + 103 + ], + [ + 107, + 149, + 156 + ], + [ + 51, + 75, + 91 + ], + [ + 204, + 174, + 114 + ], + [ + 15, + 1, + 5 + ], + [ + 41, + 67, + 80 + ], + [ + 69, + 97, + 110 + ], + [ + 89, + 117, + 133 + ], + [ + 105, + 141, + 151 + ], + [ + 106, + 148, + 155 + ] + ], + "rejected_key_adjacent_candidates": [], + "regions": { + "desk-tidy": { + "w": 37, + "h": 20, + "source_box": [ + 1035, + 254, + 350, + 201 + ], + "phase": [ + 2, + 8 + ], + "grid_agreement": 0.6552, + "file": "desk-tidy.png", + "sha256": "dc69ede204695f5d6c1d22d1221e2b19dacd425bad890421ca69258e81867c75" + } + } + }, + "chair": { + "palette": [ + [ + 38, + 58, + 91 + ], + [ + 3, + 2, + 7 + ], + [ + 40, + 61, + 92 + ], + [ + 27, + 40, + 64 + ], + [ + 61, + 92, + 127 + ], + [ + 6, + 1, + 20 + ], + [ + 27, + 39, + 56 + ], + [ + 57, + 86, + 117 + ], + [ + 58, + 90, + 122 + ], + [ + 25, + 28, + 39 + ], + [ + 44, + 58, + 66 + ], + [ + 23, + 26, + 38 + ], + [ + 10, + 2, + 24 + ], + [ + 22, + 1, + 23 + ], + [ + 35, + 59, + 90 + ], + [ + 57, + 57, + 70 + ] + ], + "rejected_key_adjacent_candidates": [], + "regions": { + "chair": { + "w": 17, + "h": 25, + "source_box": [ + 55, + 519, + 159, + 239 + ], + "phase": [ + 0, + 2 + ], + "grid_agreement": 0.6175, + "file": "chair.png", + "sha256": "2baadffff667ff4537b53bca712db9460a7c2b34d89ce46fd41f544080c771ee" + } + } + } + } +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/report-tiles.json b/hub/public/office-sample-assets/rectify-manifests/report-tiles.json new file mode 100644 index 0000000..a72c88f --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/report-tiles.json @@ -0,0 +1,187 @@ +{ + "sheet": "/tmp/claude-0/-home-user-bureau/b93c631b-2a71-5386-8aa0-92c579bc4d77/scratchpad/refs/props_sheet-tiles-surfaces-v2.jpg", + "sheet_sha256": "347acad918a4747aaa6fa0dd758bcdde00731acfe2cdde199bb9d428b28c7208", + "anchor_source_px_height": 230, + "anchor_logical_height": 48, + "pitch": 4.791666666666667, + "groups": { + "floor": { + "palette": [ + [ + 92, + 66, + 54 + ], + [ + 56, + 38, + 30 + ], + [ + 105, + 81, + 67 + ], + [ + 109, + 85, + 69 + ], + [ + 54, + 35, + 28 + ], + [ + 62, + 44, + 37 + ], + [ + 74, + 53, + 44 + ], + [ + 77, + 58, + 51 + ], + [ + 62, + 42, + 34 + ], + [ + 108, + 82, + 69 + ], + [ + 70, + 51, + 42 + ], + [ + 81, + 61, + 52 + ], + [ + 59, + 42, + 37 + ], + [ + 98, + 72, + 59 + ], + [ + 107, + 85, + 69 + ], + [ + 97, + 70, + 57 + ], + [ + 91, + 70, + 61 + ], + [ + 95, + 72, + 60 + ], + [ + 108, + 85, + 72 + ] + ], + "rejected_key_adjacent_candidates": [], + "regions": { + "floor-wood": { + "w": 55, + "h": 45, + "source_box": [ + 270, + 20, + 265, + 220 + ], + "phase": [ + 1, + 1 + ], + "grid_agreement": 0.7026, + "file": "floor-wood.png", + "sha256": "3cb9346ead438d9b667c615a1d4c18319c27b17423f7006a59873d74b59cb22e" + } + } + }, + "wall": { + "palette": [ + [ + 88, + 117, + 124 + ], + [ + 66, + 91, + 99 + ], + [ + 73, + 101, + 107 + ], + [ + 74, + 101, + 108 + ], + [ + 60, + 85, + 93 + ], + [ + 104, + 133, + 138 + ], + [ + 59, + 87, + 93 + ] + ], + "rejected_key_adjacent_candidates": [], + "regions": { + "wall-cracked": { + "w": 52, + "h": 45, + "source_box": [ + 1130, + 20, + 255, + 220 + ], + "phase": [ + 4, + 2 + ], + "grid_agreement": 0.9656, + "file": "wall-cracked.png", + "sha256": "a100932e8583fe48d98b4d0b7e4c795e59e87acf5965f5e79c479f6ecd640f0c" + } + } + } + } +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/report-typing.json b/hub/public/office-sample-assets/rectify-manifests/report-typing.json new file mode 100644 index 0000000..df950ee --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/report-typing.json @@ -0,0 +1,822 @@ +{ + "sheet": "/tmp/claude-0/-home-user-bureau/54d794df-7af3-5973-a740-419653da628f/scratchpad/t59-work/sheet-typing.jpg", + "sheet_sha256": "3d20b2e3a35577ec624293ded91e123bd7120e7f2d3587ea09b5b7cc8c958521", + "anchor_source_px_height": 356, + "anchor_logical_height": 48, + "pitch": 7.416666666666667, + "groups": { + "undercut-agent": { + "palette": [ + [ + 44, + 65, + 89 + ], + [ + 4, + 4, + 20 + ], + [ + 41, + 55, + 76 + ], + [ + 3, + 6, + 26 + ], + [ + 5, + 2, + 7 + ], + [ + 28, + 27, + 53 + ], + [ + 52, + 68, + 88 + ], + [ + 40, + 41, + 67 + ], + [ + 74, + 104, + 122 + ], + [ + 53, + 73, + 89 + ], + [ + 73, + 102, + 117 + ], + [ + 69, + 103, + 121 + ], + [ + 28, + 38, + 57 + ], + [ + 69, + 101, + 118 + ], + [ + 30, + 40, + 65 + ], + [ + 40, + 30, + 56 + ], + [ + 6, + 19, + 40 + ], + [ + 25, + 21, + 42 + ], + [ + 41, + 57, + 86 + ], + [ + 44, + 62, + 78 + ], + [ + 40, + 54, + 70 + ], + [ + 21, + 21, + 41 + ], + [ + 39, + 39, + 57 + ], + [ + 40, + 74, + 102 + ], + [ + 21, + 4, + 5 + ], + [ + 21, + 4, + 38 + ], + [ + 29, + 22, + 53 + ], + [ + 26, + 26, + 42 + ], + [ + 26, + 3, + 39 + ], + [ + 21, + 25, + 41 + ], + [ + 19, + 3, + 27 + ], + [ + 51, + 63, + 79 + ], + [ + 86, + 146, + 173 + ], + [ + 43, + 87, + 116 + ], + [ + 41, + 33, + 62 + ], + [ + 53, + 41, + 67 + ], + [ + 103, + 69, + 64 + ], + [ + 40, + 42, + 74 + ], + [ + 76, + 114, + 125 + ], + [ + 76, + 116, + 137 + ], + [ + 31, + 53, + 75 + ], + [ + 54, + 75, + 102 + ], + [ + 69, + 87, + 104 + ], + [ + 138, + 139, + 123 + ], + [ + 82, + 146, + 176 + ], + [ + 54, + 90, + 117 + ], + [ + 86, + 104, + 122 + ], + [ + 87, + 117, + 135 + ], + [ + 137, + 231, + 248 + ], + [ + 4, + 10, + 37 + ], + [ + 18, + 5, + 21 + ], + [ + 88, + 127, + 149 + ], + [ + 42, + 3, + 54 + ], + [ + 82, + 141, + 170 + ], + [ + 26, + 4, + 4 + ], + [ + 40, + 2, + 41 + ], + [ + 53, + 42, + 39 + ], + [ + 86, + 123, + 136 + ], + [ + 218, + 137, + 118 + ], + [ + 27, + 21, + 19 + ], + [ + 40, + 3, + 2 + ], + [ + 88, + 102, + 117 + ], + [ + 20, + 40, + 67 + ], + [ + 26, + 2, + 27 + ], + [ + 30, + 44, + 73 + ], + [ + 53, + 55, + 75 + ], + [ + 79, + 121, + 137 + ], + [ + 101, + 136, + 150 + ], + [ + 150, + 232, + 248 + ], + [ + 217, + 138, + 121 + ], + [ + 218, + 150, + 135 + ], + [ + 78, + 125, + 150 + ], + [ + 85, + 140, + 170 + ], + [ + 220, + 148, + 123 + ], + [ + 22, + 38, + 57 + ], + [ + 26, + 5, + 21 + ], + [ + 73, + 87, + 102 + ], + [ + 88, + 136, + 165 + ], + [ + 104, + 68, + 57 + ], + [ + 148, + 234, + 253 + ], + [ + 39, + 28, + 26 + ], + [ + 80, + 137, + 165 + ], + [ + 4, + 15, + 34 + ], + [ + 32, + 64, + 88 + ], + [ + 51, + 40, + 75 + ], + [ + 20, + 28, + 53 + ], + [ + 42, + 70, + 100 + ], + [ + 67, + 90, + 113 + ], + [ + 87, + 94, + 116 + ], + [ + 88, + 116, + 122 + ], + [ + 89, + 134, + 152 + ], + [ + 103, + 121, + 118 + ], + [ + 102, + 121, + 121 + ], + [ + 38, + 30, + 43 + ], + [ + 53, + 91, + 121 + ], + [ + 86, + 148, + 180 + ], + [ + 148, + 146, + 122 + ], + [ + 7, + 27, + 53 + ], + [ + 8, + 39, + 66 + ], + [ + 28, + 4, + 55 + ], + [ + 29, + 57, + 86 + ], + [ + 39, + 22, + 55 + ], + [ + 56, + 1, + 3 + ], + [ + 56, + 55, + 87 + ], + [ + 68, + 57, + 55 + ], + [ + 116, + 137, + 135 + ], + [ + 147, + 149, + 134 + ], + [ + 183, + 103, + 91 + ], + [ + 212, + 138, + 118 + ], + [ + 213, + 139, + 122 + ], + [ + 5, + 55, + 85 + ], + [ + 20, + 57, + 87 + ], + [ + 57, + 87, + 104 + ], + [ + 63, + 78, + 101 + ], + [ + 70, + 113, + 123 + ], + [ + 101, + 87, + 89 + ], + [ + 183, + 114, + 103 + ], + [ + 214, + 152, + 137 + ], + [ + 7, + 24, + 42 + ], + [ + 6, + 51, + 80 + ], + [ + 17, + 53, + 79 + ], + [ + 35, + 26, + 25 + ], + [ + 30, + 60, + 77 + ], + [ + 39, + 1, + 28 + ], + [ + 55, + 100, + 122 + ], + [ + 67, + 105, + 134 + ], + [ + 68, + 111, + 135 + ], + [ + 89, + 113, + 118 + ] + ], + "rejected_key_adjacent_candidates": [ + [ + 54, + 2, + 56 + ], + [ + 56, + 2, + 74 + ], + [ + 55, + 3, + 68 + ], + [ + 70, + 0, + 84 + ], + [ + 78, + 2, + 55 + ], + [ + 85, + 0, + 86 + ], + [ + 70, + 0, + 71 + ], + [ + 68, + 0, + 73 + ], + [ + 76, + 17, + 83 + ], + [ + 84, + 22, + 106 + ], + [ + 90, + 28, + 106 + ], + [ + 103, + 4, + 89 + ], + [ + 103, + 0, + 105 + ], + [ + 118, + 6, + 118 + ], + [ + 121, + 9, + 117 + ], + [ + 123, + 10, + 120 + ] + ], + "regions": { + "idle-anchor": { + "w": 20, + "h": 48, + "source_box": [ + 24, + 22, + 150, + 362 + ], + "phase": [ + 1, + 1 + ], + "grid_agreement": 0.5084, + "file": "idle-anchor.png", + "sha256": "3212ccb25078f8b7bd11d88b78f2ea0e46f01a96567a74a5778a860aea13a9b2" + }, + "typing-0": { + "w": 38, + "h": 48, + "source_box": [ + 190, + 22, + 290, + 362 + ], + "phase": [ + 1, + 0 + ], + "grid_agreement": 0.537, + "file": "typing-0.png", + "sha256": "74f52c4f5143e2931e5ec1444e4ad2f337dc8c4d3976f5e90de9d0f455d5d6f8" + }, + "typing-1": { + "w": 39, + "h": 48, + "source_box": [ + 496, + 22, + 292, + 362 + ], + "phase": [ + 0, + 0 + ], + "grid_agreement": 0.5365, + "file": "typing-1.png", + "sha256": "2b63c15999bbbc16825e02a0dc7c2d98ad2d45d25b572ce9d95c1e77385c01ee" + }, + "typing-2": { + "w": 37, + "h": 48, + "source_box": [ + 804, + 22, + 285, + 362 + ], + "phase": [ + 4, + 0 + ], + "grid_agreement": 0.5264, + "file": "typing-2.png", + "sha256": "5cf7c4b6c6fdf23e0d7ab20fd59288f4dccd70cedabf31531b7a0b208e6ae5f0" + }, + "typing-3": { + "w": 38, + "h": 48, + "source_box": [ + 1105, + 22, + 284, + 362 + ], + "phase": [ + 0, + 0 + ], + "grid_agreement": 0.5367, + "file": "typing-3.png", + "sha256": "9fbb3e993b0d0bd7703b3d083a5213f916e5ca10b6f3acc8386f64dd8bf4da81" + } + } + } + } +} \ No newline at end of file diff --git a/hub/public/office-sample-assets/rectify-manifests/report-window.json b/hub/public/office-sample-assets/rectify-manifests/report-window.json new file mode 100644 index 0000000..185e190 --- /dev/null +++ b/hub/public/office-sample-assets/rectify-manifests/report-window.json @@ -0,0 +1,153 @@ +{ + "sheet": "scenes/scene-window-states.jpg", + "sheet_sha256": "54215d540f6cfa2872e9b9fb8277415b088ecb9f76434bc86ab56cb1ddb32c26", + "anchor_source_px_height": 335, + "anchor_logical_height": 48, + "pitch": 6.979166666666667, + "groups": { + "window": { + "palette": [ + [ + 5, + 69, + 88 + ], + [ + 2, + 29, + 54 + ], + [ + 4, + 38, + 57 + ], + [ + 4, + 41, + 66 + ], + [ + 27, + 87, + 105 + ], + [ + 2, + 21, + 40 + ], + [ + 21, + 86, + 103 + ], + [ + 5, + 25, + 41 + ], + [ + 5, + 54, + 74 + ], + [ + 19, + 78, + 101 + ], + [ + 1, + 7, + 26 + ], + [ + 39, + 89, + 104 + ], + [ + 1, + 6, + 20 + ], + [ + 18, + 39, + 56 + ], + [ + 19, + 74, + 92 + ], + [ + 29, + 92, + 111 + ], + [ + 8, + 73, + 90 + ], + [ + 0, + 34, + 60 + ], + [ + 19, + 40, + 65 + ], + [ + 19, + 69, + 88 + ], + [ + 19, + 30, + 52 + ], + [ + 28, + 68, + 90 + ], + [ + 41, + 102, + 115 + ], + [ + 26, + 80, + 101 + ] + ], + "rejected_key_adjacent_candidates": [], + "regions": { + "window-blue-night": { + "w": 36, + "h": 31, + "source_box": [ + 207, + 420, + 257, + 219 + ], + "phase": [ + 3, + 1 + ], + "grid_agreement": 0.3748, + "file": "window-blue-night.png", + "sha256": "44f1ead3c0b5ea9688dc7dbc150ad5e6f027b15d267917a5960b586c21b53505" + } + } + } + } +} \ No newline at end of file diff --git a/hub/public/office-sample.html b/hub/public/office-sample.html new file mode 100644 index 0000000..61a0bc8 --- /dev/null +++ b/hub/public/office-sample.html @@ -0,0 +1,247 @@ + + + + + +Bureau · office sample scene + + + +
+ BUREAU·office · sample scene (t-59)rectifier round · corner rebuilt entirely from v2 reference sheets · static, no live events +
+
+
+
+ + + idle = the "idle" heartbeat verb · typing = the "editing" verb (docs/protocol.md) +
+
+ About this sample — round 28: face/hair/shading craft gap — palette-starvation root cause fixed (routing note: t-59 itself is discarded, see below) +
+
+
+ + + + diff --git a/hub/tools/rectify.py b/hub/tools/rectify.py new file mode 100644 index 0000000..e0074c8 --- /dev/null +++ b/hub/tools/rectify.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python3 +# rectify.py — Bureau office-art rectifier (t-54 engine outcome, boss ruling 2026-08-16). +# +# No generator output ships raw. Every office sprite passes through this tool: +# the generated sheet is a photo OF pixel art, and this script re-derives the +# true pixels on our own grid instead of shipping the photo's noise. Builders +# extract with it, the critic verifies with it, by running it again on the +# same cited region and diffing the output byte-for-byte. No private pipelines. +# +# Licensed Apache-2.0 (LICENSE-APACHE at the repo root), unlike the AGPL hub: +# this is a build-time art tool, meant to run anywhere the brain's reference +# sheets live, not part of the deployed hub server. +# +# Pipeline (docs cross-ref: projects/bureau/references/office/PROMPTS.md, +# "Rectification: from generator output to shipped sprite"): +# 1. Grid fit — per-sheet pixel pitch, derived from a measured anchor +# figure's height (never autocorrelation guesswork: the +# anchor is exact and every sheet carries one), plus a +# deterministic phase search so cell boundaries land on +# the source art's own edges instead of straddling them. +# 2. Cell vote — each output pixel is the dominant (mode) color among the +# source pixels its grid cell covers. Whole-cell decision, +# never a resample: this is what erases JPEG ringing and +# anti-aliasing by construction. +# 3. Shared palette — every region in a "group" (e.g. one character's whole +# frame set) is cell-voted first, THEN all of their votes +# are palette-reduced TOGETHER, so the same true color +# always snaps to the same output value in every frame +# (the t-59 round-21 lesson: never quantize per-frame). +# Small, rare, highly-saturated colors (status lights, +# etc.) are exempted from reduction and kept exact. +# 4. Key to alpha — pixels near the sheet's magenta background become +# transparent. +# 5. The scaler — pitch IS the scaling knob. Because pitch is derived from +# the sheet's own 48px-tall anchor figure, cell-voting at +# that pitch produces correctly-scaled output directly; +# there is no separate resize step, so pixel art is never +# post-scaled (which would blur or reintroduce aliasing). +# 6. Output — true native-resolution transparent PNG, 1 logical pixel +# = 1 file pixel. Upscaling (nearest-neighbor only) is a +# render-time concern, not baked into the asset. +# +# Determinism: no randomness anywhere (palette reduction is frequency-sorted +# greedy bucketing, not k-means; phase search is an exhaustive deterministic +# scan). Same manifest + same source bytes -> byte-identical PNG output, +# every time — this is what makes "the critic re-runs the rectifier on the +# cited region and checks for a byte-identical sprite" a mechanical check +# instead of a matter of taste. +# +# Usage: +# python3 rectify.py MANIFEST.json --out-dir OUT/ +# +# Requires: pillow, numpy (pip install pillow numpy). Not zero-dependency — +# deliberate: this is an offline build tool an agent session runs locally +# against checked-out reference sheets, not code the hub server ships or +# executes at request time. See docs/protocol.md's zero-dependency clause, +# which scopes to the hub core. +# +# MANIFEST.json shape: +# { +# "sheet": "relative/path/to/sheet.jpg", +# "background_key": [255, 0, 255], // magenta, the sheets' own key +# "key_tolerance": 60, // OUTER band edge: channel-distance beyond which a cell is fully opaque +# "key_inner_tolerance": 21, // INNER band edge (default: 0.35 * key_tolerance): fully background within this distance. Between the two, alpha ramps and color is decontaminated — this is what kills fringe halos that a single hard threshold can't. +# "edge_hue_tolerance": 40, // (round 27) an edge cell's DECONTAMINATED color is still checked for the key's own hue signature (min(R,B)-G, see magenta_hue_score) — decontamination is a linear approximation and can leave a residual tint on isolated cells; one that scores above this is dropped to fully transparent rather than shipped tinted. Default 40 is calibrated against this atlas's committed sprites (real content <=28, prior defects 62-92); tune per-sheet only with evidence, same as the other tolerances. +# "anchor_region": [x, y, w, h], // rough box containing the sheet's +# // own 48px-tall anchor figure — +# // padding is fine, tight-cropped +# // automatically from non-background pixels +# "anchor_logical_height": 48, +# "palette_size": 28, +# "accent_saturation_min": 0.5, +# "accent_max_area_frac": 0.01, +# "regions": [ +# {"name": "typing-0", "x": .., "y": .., "w": .., "h": .., "group": "undercut-agent"}, +# ... +# ] +# } +# +# One manifest = one sheet = one pitch (one generation scale). A region's +# output size is never specified directly — it falls out of dividing its +# source pixel box by the sheet's own pitch, which is exactly "every prop's +# target = its ratio to the anchor figure standing in its sheet." + +import sys +import json +import hashlib +from pathlib import Path + +from PIL import Image +import numpy as np +from scipy import ndimage + + +def load_rgb(path): + im = Image.open(path).convert('RGB') + return np.asarray(im, dtype=np.int16) # signed so distance math doesn't wrap + + +def color_dist(arr, color): + # Chebyshev-ish combined channel distance, cheap and stable. + d = arr - np.array(color, dtype=np.int16) + return np.sqrt((d.astype(np.int32) ** 2).sum(axis=-1)) + + +def measure_anchor_height(img, box, bg_color, bg_tol): + # Deliberately robust to a loose/generous anchor_region box (a neighbor + # row's stray feet or a divider sliver touching the box edge should not + # skew the measurement): label connected components of non-background + # pixels and measure the LARGEST one's bounding box, rather than the + # naive bbox of every non-background pixel in the box (which merges in + # anything that happens to touch the same box, even barely). + x, y, w, h = box + crop = img[y:y + h, x:x + w] + dist = color_dist(crop, bg_color) + mask = dist > bg_tol + labeled, n = ndimage.label(mask) + if n == 0: + raise SystemExit(f'anchor_region {box}: no non-background pixels found') + sizes = ndimage.sum(mask, labeled, range(1, n + 1)) + biggest = int(np.argmax(sizes)) + 1 + rows = np.where((labeled == biggest).any(axis=1))[0] + return int(rows.max() - rows.min() + 1), int(y + rows.min()) + + +def mode_color(pixels): + # pixels: (N,3) int array for one cell. Deterministic dominant-color vote: + # coarse-quantize to merge JPEG-noise near-duplicates, count buckets, take + # the most frequent (ties broken by lowest bucket tuple — deterministic), + # then report the TRUE average color of the pixels that fell in that + # bucket (not the bucket's own quantized center) so the vote stays close + # to the source, not to an arbitrary grid. + if pixels.shape[0] == 0: + return None + q = (pixels // 16) * 16 + keys = q[:, 0].astype(np.int64) * 65536 + q[:, 1].astype(np.int64) * 256 + q[:, 2].astype(np.int64) + uniq, inverse, counts = np.unique(keys, return_inverse=True, return_counts=True) + best_count = counts.max() + # deterministic tie-break: smallest bucket key among those at max count + winners = uniq[counts == best_count] + winner_key = winners.min() + sel = pixels[keys == winner_key] + avg = sel.mean(axis=0) + return tuple(int(round(c)) for c in avg) + + +def magenta_hue_score(rgb, bg_color): + # How strongly a color still carries the KEY's own hue (not just its + # brightness/distance) — the key is near-pure magenta (R and B both far + # above G), so this is min(R,B) - G. Distance-to-background (color_dist) + # and hue score measure different things: a dark, near-black pixel can + # sit far from the key in Euclidean distance (safe by that test) while a + # bright, desaturated one can be hue-clean but numerically close. Round + # 26's critic pass found isolated edge-band pixels that clear outer_tol + # (so cell_vote_grid's linear decontamination fires and ships them) but + # still read as visibly magenta-tinted after decontamination — the + # per-cell alpha_frac (itself only an approximation of true alpha, since + # it comes from a single scalar distance) understates the real blend + # fraction, so solving for fg leaves a residual tint. This score is a + # bg-agnostic gate for that residue, independent of build_shared_palette's + # existing Euclidean-distance safety net (which catches a different + # failure mode: opaque interior votes that drifted back toward the key + # through bucket-averaging, see its own comment). + r, g, b = rgb + bg_r, bg_g, bg_b = bg_color + if min(bg_r, bg_b) - bg_g <= 0: + return 0 # background isn't magenta-hued (custom key); this net doesn't apply + return min(r, b) - g + + +def cell_vote_grid(img, region, pitch, dx, dy, bg_color, inner_tol, outer_tol, edge_hue_tol=40): + # Soft chroma-key with decontamination, not a hard threshold. A cell + # voted color's distance to the background color puts it in one of + # three bands: + # dist <= inner_tol -> fully background (alpha 0) + # dist >= outer_tol -> fully foreground (alpha 255) + # inner_tol < dist < outer_tol -> an EDGE cell: the source pixel is a + # genuine anti-aliased blend of foreground and background (this is + # exactly what a hard single threshold gets wrong — it either kept + # these as opaque near-magenta ["fringe halo"] or discarded them as + # background [a hard, aliased edge]). Alpha ramps linearly across + # the band, and the color is DECONTAMINATED: solve + # observed = alpha_frac*fg + (1-alpha_frac)*bg for fg, so the + # magenta tint mixed into the edge blend is removed rather than + # shipped as part of the sprite's own color. + # Hue safety net (round 27): even after decontamination, a handful of + # isolated edge cells can still carry the key's own magenta hue + # signature (see magenta_hue_score) — the round-26 critic pass found + # 4 such pixels atlas-wide (all in the edge band, all isolated single + # cells at a hard alpha-0/alpha-255 boundary), none caught by the + # existing Euclidean-distance checks. A cell whose decontaminated + # color still scores above edge_hue_tol is dropped to fully + # transparent instead: the linear unmix has already shown itself + # unreliable for this cell (that's exactly what the residual hue + # means), and the sprite's real edge is carried by the next cell in, + # which is untouched — so there is nothing to reconstruct, only a + # wrong-colored pixel to not ship. 40 is calibrated against this + # atlas's own committed sprites: every legitimate opaque or edge + # pixel scores <=28 (measured atlas-wide, see t-59 round-27 log), + # the 4 defects score 62-92 — a wide, clean margin either side. + x, y, w, h = region + n_cols = int((w - dx) // pitch) + n_rows = int((h - dy) // pitch) + colors = [[None] * n_cols for _ in range(n_rows)] + alphas = [[0] * n_cols for _ in range(n_rows)] + agree_sum = 0.0 + agree_n = 0 + bg_arr = np.array(bg_color, dtype=np.float64) + for r in range(n_rows): + cy0 = y + dy + int(round(r * pitch)) + cy1 = y + dy + int(round((r + 1) * pitch)) + for c in range(n_cols): + cx0 = x + dx + int(round(c * pitch)) + cx1 = x + dx + int(round((c + 1) * pitch)) + cell = img[cy0:cy1, cx0:cx1].reshape(-1, 3) + if cell.shape[0] == 0: + continue + col = mode_color(cell) + d = float(color_dist(np.array([col]), bg_color)[0]) + if d <= inner_tol: + alphas[r][c] = 0 + colors[r][c] = col + elif d >= outer_tol: + alphas[r][c] = 255 + colors[r][c] = col + else: + frac = (d - inner_tol) / (outer_tol - inner_tol) + fg = (np.array(col, dtype=np.float64) - (1 - frac) * bg_arr) / max(frac, 1e-6) + fg = np.clip(fg, 0, 255) + fg_tuple = tuple(int(round(v)) for v in fg) + if magenta_hue_score(fg_tuple, bg_color) > edge_hue_tol: + alphas[r][c] = 0 + colors[r][c] = col + else: + alphas[r][c] = int(round(255 * frac)) + colors[r][c] = fg_tuple + # agreement score for phase search: fraction of the cell matching + # the coarse bucket that won the vote (crisp cells -> high score) + q = (cell // 16) * 16 + keys = q[:, 0].astype(np.int64) * 65536 + q[:, 1].astype(np.int64) * 256 + q[:, 2].astype(np.int64) + _, counts = np.unique(keys, return_counts=True) + agree_sum += counts.max() / cell.shape[0] + agree_n += 1 + score = agree_sum / agree_n if agree_n else 0.0 + return colors, alphas, score, n_cols, n_rows + + +def best_phase(img, region, pitch, bg_color, inner_tol, outer_tol, steps=6): + # Exhaustive, deterministic search over a small grid of candidate phase + # offsets (fractions of one pitch). Picks the offset whose cell-voted + # grid has the highest average per-cell color agreement — the proxy for + # "cell boundaries land on the art's real edges" per the doc's grid-fit + # step. steps=6 keeps this fast; determinism doesn't need finer. + best = None + for i in range(steps): + dx = int(round(i * pitch / steps)) + for j in range(steps): + dy = int(round(j * pitch / steps)) + _, _, score, n_cols, n_rows = cell_vote_grid(img, region, pitch, dx, dy, bg_color, inner_tol, outer_tol) + if n_cols < 1 or n_rows < 1: + continue + key = (score, -dx, -dy) # deterministic tie-break: prefer smaller offsets + if best is None or key > best[0]: + best = (key, dx, dy) + if best is None: + return 0, 0 + return best[1], best[2] + + +def hsv_saturation(rgb): + r, g, b = [c / 255.0 for c in rgb] + mx, mn = max(r, g, b), min(r, g, b) + if mx == 0: + return 0.0 + return (mx - mn) / mx + + +def build_shared_palette(all_votes, palette_size, accent_sat_min, accent_area_frac, bg_color, outer_tol, edge_hue_tol=40): + # all_votes: list of (color, count) already-collected non-background cell + # colors across every region in a group, count = how many cells voted it. + total = sum(c for _, c in all_votes) + # Coarse-bucket to merge near-duplicate votes (anti-aliasing / residual + # noise the per-cell vote didn't already erase), frequency-sorted, + # deterministic (Python sort is stable; ties broken by bucket key below). + buckets = {} + for color, count in all_votes: + q = tuple((c // 12) * 12 for c in color) + b = buckets.setdefault(q, {'count': 0, 'sum': [0, 0, 0]}) + b['count'] += count + b['sum'][0] += color[0] * count + b['sum'][1] += color[1] * count + b['sum'][2] += color[2] * count + ranked = sorted(buckets.items(), key=lambda kv: (-kv[1]['count'], kv[0])) + + accents = [] + base = [] + rejected = [] + for q, b in ranked: + avg = tuple(round(b['sum'][i] / b['count']) for i in range(3)) + # Safety net, independent of how a bucket got here: every vote that + # fed this bucket individually cleared outer_tol (only alpha==255 + # cells are ever counted, see main()), but bucket AVERAGING is a + # convex combination — it can land closer to the background color + # than any single vote that produced it (e.g. two genuine but + # differently-shifted skin-highlight votes straddling the magenta + # axis and averaging toward it). A palette entry that's drifted + # back into key-adjacent territory is exactly what caused fringe- + # colored blotches in round 23: reject it outright rather than let + # snap_to_palette ever hand it out as a "safe" target. + if color_dist(np.array([avg]), bg_color)[0] < outer_tol: + rejected.append(avg) + continue + # Round 28: the Euclidean-distance net above only catches a bucket + # average that drifted NUMERICALLY close to the key — it does not + # catch a bucket that is numerically far (safe by that test) but + # still carries the key's own HUE signature (dark, low-saturation + # magenta/violet: R and B both meaningfully above G). Round 27 added + # exactly this hue check (magenta_hue_score) for cell_vote_grid's + # edge band, but scoped it there because that round's own defects + # were all edge-band cells; it was never threaded into the palette + # path. Raising palette_size (t-59 round 28, recovering the + # face/hair shading ramp a too-small palette was truncating away) + # gave several dark-violet buckets — e.g. (54,2,56), hue score 52 — + # enough vote count to survive the frequency cut for the first time; + # they cleared this function's own Euclidean net by a wide margin + # (dist ~283 vs outer_tol) but scored 52-54 on the same hue test, + # over the edge band's own edge_hue_tol=40 bound. Same defect class + # round 25's comment above already names ("fringe-colored blotches"), + # just reachable via a path (opaque interior votes -> palette + # candidate) that only a bigger palette exposes — so the fix belongs + # here, at the one place ALL palette candidates pass through, + # not as a per-pixel patch and not by shrinking palette_size back + # down (which would just re-hide it behind starvation again). + if magenta_hue_score(avg, bg_color) > edge_hue_tol: + rejected.append(avg) + continue + frac = b['count'] / total if total else 0 + sat = hsv_saturation(avg) + if sat >= accent_sat_min and frac <= accent_area_frac: + accents.append(avg) + else: + base.append((avg, b['count'])) + base = base[:palette_size] + palette = [c for c, _ in base] + accents + return palette, rejected + + +def snap_to_palette(color, palette): + best_i, best_d = 0, None + for i, p in enumerate(palette): + d = sum((a - b) ** 2 for a, b in zip(color, p)) + if best_d is None or d < best_d or (d == best_d and i < best_i): + best_d, best_i = d, i + return palette[best_i] + + +def main(): + if len(sys.argv) < 2: + print('usage: rectify.py MANIFEST.json --out-dir OUT/', file=sys.stderr) + sys.exit(2) + manifest_path = Path(sys.argv[1]) + out_dir = Path('rectified-out') + if '--out-dir' in sys.argv: + out_dir = Path(sys.argv[sys.argv.index('--out-dir') + 1]) + out_dir.mkdir(parents=True, exist_ok=True) + + m = json.loads(manifest_path.read_text()) + sheet_path = (manifest_path.parent / m['sheet']) if not Path(m['sheet']).is_absolute() else Path(m['sheet']) + img = load_rgb(sheet_path) + bg = tuple(m.get('background_key', [255, 0, 255])) + # Two-band soft key (see cell_vote_grid's own comment): outer_tol is the + # existing "key_tolerance" (fully foreground beyond this distance from + # the background color); inner_tol defaults to a fraction of it (fully + # background within this distance). The band between the two is where + # real anti-aliased edge pixels live — ramped alpha + decontamination, + # not a hard cutoff, is what actually kills fringe halos. + outer_tol = m.get('key_tolerance', 60) + inner_tol = m.get('key_inner_tolerance', outer_tol * 0.35) + edge_hue_tol = m.get('edge_hue_tolerance', 40) + if 'anchor_source_px_height' in m: + # Manual override (spec's "or manual parameter" alternative to + # auto grid-fit) — for sheets where connected-component detection + # bridges across a thin dark divider line into a neighboring + # figure (e.g. two stacked anchor repeats with no true background + # gap between them). Measured by eye against a ruler overlay. + anchor_h_px = m['anchor_source_px_height'] + else: + anchor_h_px, _ = measure_anchor_height(img, m['anchor_region'], bg, outer_tol) + anchor_logical_h = m.get('anchor_logical_height', 48) + pitch = anchor_h_px / anchor_logical_h + + groups = {} + for r in m['regions']: + groups.setdefault(r.get('group', 'default'), []).append(r) + + report = { + 'sheet': str(m['sheet']), + 'sheet_sha256': hashlib.sha256(sheet_path.read_bytes()).hexdigest(), + 'anchor_source_px_height': anchor_h_px, + 'anchor_logical_height': anchor_logical_h, + 'pitch': pitch, + 'groups': {}, + } + + for group_name, regions in groups.items(): + region_grids = {} + vote_counts = {} + for r in regions: + box = (r['x'], r['y'], r['w'], r['h']) + dx, dy = best_phase(img, box, pitch, bg, inner_tol, outer_tol) + colors, alphas, score, n_cols, n_rows = cell_vote_grid(img, box, pitch, dx, dy, bg, inner_tol, outer_tol, edge_hue_tol) + region_grids[r['name']] = { + 'colors': colors, 'alphas': alphas, 'n_cols': n_cols, 'n_rows': n_rows, + 'dx': dx, 'dy': dy, 'score': score, 'box': box, + } + for row in range(n_rows): + for col in range(n_cols): + # Only fully-opaque cells vote on the shared palette — + # partial-alpha edge cells are decontaminated estimates, + # noisier than an interior vote, and shouldn't skew what + # the palette snaps everything else to. + if alphas[row][col] != 255: + continue + c = colors[row][col] + vote_counts[c] = vote_counts.get(c, 0) + 1 + + palette, rejected = build_shared_palette( + list(vote_counts.items()), + m.get('palette_size', 28), + m.get('accent_saturation_min', 0.5), + m.get('accent_max_area_frac', 0.01), + bg, outer_tol, edge_hue_tol, + ) + if rejected: + print(f' [{group_name}] rejected {len(rejected)} key-adjacent palette candidate(s): {rejected}') + + report['groups'][group_name] = { + 'palette': [list(p) for p in palette], + 'rejected_key_adjacent_candidates': [list(p) for p in rejected], + 'regions': {}, + } + + for r in regions: + g = region_grids[r['name']] + n_cols, n_rows = g['n_cols'], g['n_rows'] + out = np.zeros((n_rows, n_cols, 4), dtype=np.uint8) + for row in range(n_rows): + for col in range(n_cols): + a = g['alphas'][row][col] + if a == 0: + continue + if a == 255: + # Fully opaque: snap to the shared palette as before. + out[row, col, 0:3] = snap_to_palette(g['colors'][row][col], palette) + else: + # Partial-alpha edge cell: ship the decontaminated + # color as-is, not palette-snapped — it's meant to + # blend toward transparency, and force-snapping it + # to a palette entry derived from opaque interior + # votes would just reintroduce a hard, wrong-colored + # edge under a different name. + out[row, col, 0:3] = g['colors'][row][col] + out[row, col, 3] = a + im = Image.fromarray(out, mode='RGBA') + out_path = out_dir / f'{r["name"]}.png' + im.save(out_path, optimize=False) + report['groups'][group_name]['regions'][r['name']] = { + 'w': n_cols, 'h': n_rows, + 'source_box': list(g['box']), + 'phase': [g['dx'], g['dy']], + 'grid_agreement': round(g['score'], 4), + 'file': out_path.name, + 'sha256': hashlib.sha256(out_path.read_bytes()).hexdigest(), + } + + report_path = out_dir / 'rectify-report.json' + report_path.write_text(json.dumps(report, indent=1)) + print(f'wrote {len(m["regions"])} sprites + report to {out_dir}') + print(f'pitch = {pitch:.4f} (anchor {anchor_h_px}px -> {anchor_logical_h}px)') + + +if __name__ == '__main__': + main()