From c94f7953bb38e900439cd29f074ccfa5bc116641 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:41:59 +0000 Subject: [PATCH] t-141: stage-1 sprite extraction (individual PNGs, no scene/page) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t-59 (the old monolithic office-sample-scene loop) was discarded by the boss's re-scope and replaced by three stage-separated missions; this is t-141, stage 1 (sprites only). Its own Acceptance is explicit that the deliverable is "individual transparent, native-resolution PNGs... No scene, no animation, no page" — a different shape than what 28 rounds of t-59 work had been producing (a single composed atlas.png + an office-sample.html demo page). This commit is that stage-1 deliverable, built fresh off main (rectify.py never lived on main; t-59's branch was never merged) rather than carrying over t-59's scene/page files. Brings over from t-59's branch (discarded mission, reusable tool): - hub/tools/rectify.py at its round-28 state (commit 0298b16 on t-59-office-sample-scene): grid-fit + per-cell-vote + key-to-alpha + 48px-anchor scaling, plus round 27/28's fixes to build_shared_palette (a two-band hue+distance gate against key-color residue in palette candidates, not just edge cells). - The 4 rectify-manifests/ (typing4/desk/tiles/window) citing each sprite's exact source sheet + region + pitch, unchanged. New: hub/public/office-sample-assets/sprites/{agent,desk,tiles,window}/, 10 individual transparent PNGs (idle-anchor, typing-0..3, desk-tidy, chair, floor-wood, wall-cracked, window-blue-night) + each group's own rectify-report.json (the tool's own output — source sheet, exact region box, phase, pitch, sha256 — IS the citation manifest the Acceptance asks for, not hand-transcribed). sprites/MANIFEST.md indexes the set and states what's verified. Verified fresh on this branch, not assumed from t-59's own claims: - Re-ran all 4 manifests against the vendored tool + committed manifests; every one of the 10 output PNGs is sha256-identical to what's committed (byte-for-byte, not just visually). - Determinism: re-ran the typing4 manifest twice, byte-identical both times. - Zero key-adjacent opaque pixels: full pixel sweep (not sampled) over all 10 PNGs using the same distance+hue test build_shared_palette itself uses (color_dist < 60 OR magenta_hue_score > 40 on any fully-opaque pixel) — 0 hits. - Each rectify-report.json confirms every group (undercut-agent, desk's own group, tiles' own group, window's own group) built its palette from only its own region votes — THE PALETTE LAW's per-subject isolation, read directly off rectify.py's main(), not just asserted. Disclosed, not silently dropped (sprites/MANIFEST.md's own "NOT yet covered" section): - Two named prop sprites — CRT monitor, wall monitor — have no manifest yet; the 4 manifests here predate this mission. Next round. - A second, alternate round-28 tool fix exists on a side branch (bettik-t141-palette-law-handoff, commit 58ad5b9): restores accent_max_area_frac to its documented 0.01 default (the mechanism the mission's own Palette Law text names — "small saturated accents are exempted and preserved") plus a freshly-calibrated palette_hue_tolerance (25, margin-checked against every disclosed bad-pixel example and every real skin/hair/suit color in these sheets), instead of 0298b16's palette_size 28->128 bump + a reused (not recalibrated) hue tolerance. Both independently verified clean on every mechanical check; not merged here since 0298b16's version is what these committed sprites were actually rectified from — flagging the alternative for a future round to diff and potentially swap in, not silently picking a winner. - Face/hair precision still reads softer than the reference sheets at this native pixel count (both round-28 attempts disclose the same gap) — a critic judgment call per this mission's own Acceptance, not a palette-law violation. Gate stays critic; parking for review, not self-declared done. --- .gitignore | 1 + .../rectify-manifests/manifest-desk.json | 39 + .../rectify-manifests/manifest-tiles.json | 38 + .../rectify-manifests/manifest-typing4.json | 63 ++ .../rectify-manifests/manifest-window.json | 32 + .../rectify-manifests/report-desk.json | 257 ++++++ .../rectify-manifests/report-tiles.json | 187 ++++ .../rectify-manifests/report-typing.json | 822 ++++++++++++++++++ .../rectify-manifests/report-window.json | 153 ++++ .../office-sample-assets/sprites/MANIFEST.md | 62 ++ .../sprites/agent/idle-anchor.png | Bin 0 -> 1539 bytes .../sprites/agent/rectify-report.json | 822 ++++++++++++++++++ .../sprites/agent/typing-0.png | Bin 0 -> 3051 bytes .../sprites/agent/typing-1.png | Bin 0 -> 3195 bytes .../sprites/agent/typing-2.png | Bin 0 -> 3115 bytes .../sprites/agent/typing-3.png | Bin 0 -> 3192 bytes .../sprites/desk/chair.png | Bin 0 -> 480 bytes .../sprites/desk/desk-tidy.png | Bin 0 -> 760 bytes .../sprites/desk/rectify-report.json | 257 ++++++ .../sprites/tiles/floor-wood.png | Bin 0 -> 1576 bytes .../sprites/tiles/rectify-report.json | 187 ++++ .../sprites/tiles/wall-cracked.png | Bin 0 -> 366 bytes .../sprites/window/rectify-report.json | 174 ++++ .../sprites/window/window-blue-night.png | Bin 0 -> 2180 bytes hub/tools/rectify.py | 483 ++++++++++ 25 files changed, 3577 insertions(+) create mode 100644 hub/public/office-sample-assets/rectify-manifests/manifest-desk.json create mode 100644 hub/public/office-sample-assets/rectify-manifests/manifest-tiles.json create mode 100644 hub/public/office-sample-assets/rectify-manifests/manifest-typing4.json create mode 100644 hub/public/office-sample-assets/rectify-manifests/manifest-window.json create mode 100644 hub/public/office-sample-assets/rectify-manifests/report-desk.json create mode 100644 hub/public/office-sample-assets/rectify-manifests/report-tiles.json create mode 100644 hub/public/office-sample-assets/rectify-manifests/report-typing.json create mode 100644 hub/public/office-sample-assets/rectify-manifests/report-window.json create mode 100644 hub/public/office-sample-assets/sprites/MANIFEST.md create mode 100644 hub/public/office-sample-assets/sprites/agent/idle-anchor.png create mode 100644 hub/public/office-sample-assets/sprites/agent/rectify-report.json create mode 100644 hub/public/office-sample-assets/sprites/agent/typing-0.png create mode 100644 hub/public/office-sample-assets/sprites/agent/typing-1.png create mode 100644 hub/public/office-sample-assets/sprites/agent/typing-2.png create mode 100644 hub/public/office-sample-assets/sprites/agent/typing-3.png create mode 100644 hub/public/office-sample-assets/sprites/desk/chair.png create mode 100644 hub/public/office-sample-assets/sprites/desk/desk-tidy.png create mode 100644 hub/public/office-sample-assets/sprites/desk/rectify-report.json create mode 100644 hub/public/office-sample-assets/sprites/tiles/floor-wood.png create mode 100644 hub/public/office-sample-assets/sprites/tiles/rectify-report.json create mode 100644 hub/public/office-sample-assets/sprites/tiles/wall-cracked.png create mode 100644 hub/public/office-sample-assets/sprites/window/rectify-report.json create mode 100644 hub/public/office-sample-assets/sprites/window/window-blue-night.png create mode 100644 hub/tools/rectify.py diff --git a/.gitignore b/.gitignore index 9c05738..ddfb74d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ brain/ .DS_Store *.log bureau-internal/ +__pycache__/ 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-assets/sprites/MANIFEST.md b/hub/public/office-sample-assets/sprites/MANIFEST.md new file mode 100644 index 0000000..ca297cc --- /dev/null +++ b/hub/public/office-sample-assets/sprites/MANIFEST.md @@ -0,0 +1,62 @@ +# Sprite set (t-141, goal: t-54) — stage 1 output + +Individual, transparent, native-resolution PNGs produced by `hub/tools/rectify.py` +against the manifests in `../rectify-manifests/`. No composed atlas, no scene, +no page — each subject's frames were rectified as their own group, so each +group's palette is derived from that subject's own source pixels only (THE +PALETTE LAW this mission's own body names as binding). + +Each subdirectory holds its sprite PNGs plus the `rectify-report.json` the +tool itself writes — that report **is** the manifest citing each sprite's +exact source sheet, source region (`source_box`), phase, pitch, and content +sha256; nothing here is hand-transcribed. + +| Group | File | Source sheet | Region | +|---|---|---|---| +| agent | idle-anchor.png | references/office/cast/sheet-agent-undercut-typing.jpg | idle-anchor | +| agent | typing-0.png .. typing-3.png | same sheet | typing-0..3 | +| desk | desk-tidy.png | references/office/props/sheet-desks-workstations-v2.jpg | desk-tidy | +| desk | chair.png | same sheet | chair | +| tiles | floor-wood.png | references/office/props/sheet-tiles-surfaces-v2.jpg | floor-wood | +| tiles | wall-cracked.png | same sheet | wall-cracked | +| window | window-blue-night.png | references/office/scenes/scene-window-states.jpg | window | + +Source JPEGs are never committed to the repo (convention predating this +mission) — the manifests' `sheet` paths resolve relative to +`rectify-manifests/`, fetched from `projects/bureau/references/office/` in +the brain at build time. + +## Verified + +- Determinism: re-ran `manifest-typing4.json` twice, all 5 output PNGs + byte-identical (sha256-matched) both runs. +- Zero key-adjacent opaque pixels: scanned every PNG above, full pixel sweep + (not sampled), same distance+hue test `rectify.py`'s own palette builder + uses (`color_dist` < 60 or `magenta_hue_score` > 40 on any fully-opaque + pixel) — 0 hits across all 10 files. +- Scale: every group's `anchor_logical_height` is 48, so pitch is derived + per-group from each sheet's own measured anchor — consistent with the + 48px system across the whole set. +- Palette law: each group (`undercut-agent`, desk's own group, tiles' own + group, window's own group) builds its palette from only its own region + votes — confirmed by reading `rectify.py`'s `main()`, which groups + `m['regions']` by `r.get('group', ...)` before calling + `build_shared_palette` once per group, never across groups. +- Carries round 27/28's tool-level palette-starvation fix (this branch, + commits 3071ab5/0298b16): `build_shared_palette`'s frequency-cut + `palette_size` and the `edge_hue_tol` second gate, both already present + in `rectify.py` at the commit these sprites were rectified from. + +## NOT yet covered (disclosed gap, not silently dropped) + +The mission body's full prop list also names a **CRT monitor** and a **wall +monitor** — no manifest exists yet for either (the 4 manifests here predate +this mission and were written for the old composed-scene demo, which didn't +need those two as separate sprites). Two more manifests + regions, same +process, is the natural next round. + +The agent's face/hair still reads softer than the reference sheets at this +native pixel count (round 28's own disclosed gap, `frames.json`'s +`round28Fixes.stillOpen` on the office-sample-scene branch) — a precision +gap, not a palette-law violation; the critic's own call per this mission's +Acceptance. diff --git a/hub/public/office-sample-assets/sprites/agent/idle-anchor.png b/hub/public/office-sample-assets/sprites/agent/idle-anchor.png new file mode 100644 index 0000000000000000000000000000000000000000..7749db04f4bcf3a447c3a1dd3a303ed5700da324 GIT binary patch literal 1539 zcmV+e2K@PnP)ARV6~}+~`Z;#uJjaghC~-y;CQc@4RfbH5X)=HcVrGR<%dQfOBEb@Ys;YdbDy^zY zZKtY=V8n(cV!?_9Aa<3y7$K!FG-WZ9O#FhP@g%M0+3{BrJC5&R@#5!k{Lx{KWa+*0 z&iUW-bCei4;YnDSY;TXw>%k{dD@C)Jrj@ z4FE->X_$IXU272FkG}mh0MBP;ng8;ac=o01JTL)b+VH?UriDMedWD}o|8o#W$yx?QxO&d6dw%iZfgmlYL|logb;Wl2znHGD<~@}AVD3HlocG9ln#0_@Qkc1zaWcGen%`T zDJ#n_h-D?^#_!(l0Bilydb$hPH+@k*A+gIBD3)DNAvUvXD09Tz`Dbv}KkQHvZ6kV! z`OFhI8#g;{O*@8*=K$Dr?qEwDWsb;X=0WPd$hk9f9bl*sqB|3$!MhRS=4q6wEX`gd z=k79>o?~nC-@f1nH{RgCxh%i_&C4ir1S$klN0~~;o!0}h_AkRU)f}3{$n44J! z;KKQ9+*~aH@bXXIM43v5RdyiSX<=DhR9K+`LwugQ<9{3p*nzrywMTEm~DGj z0%d9EMgc4=ToH9B$grYZDBiW!`=7!1^?~xcD^Mvq9;~#(fgD(>s^%7Yq?D4tPj-jqJ z%f5h9aQq@w=7`dv_sxz(+WwVmfR``;D{e7AdA1D*5Y;1?p#~S{zv6$GB!i_QjD~|g zZ|U+ih=w^$Dlo*t!WDtmOo@h}c);4mO#l*sY1!5HNHa~f*6@?_$*omCAzIa#O)gQE z3K9;2X|ye@?B>x&L4T^1R2_YsXC!WQxct>iSGjcgdjM4S_W}G%ppQIHEP&Esnb>HO zMEVkmTuB_Wg=pIss@A;I|8?s_L=T})4lyHPaKQ%FPHNQxu!j|!_ilUfph7Z{VJtZX zCqONVGfrZ195~C`#!W7qzvhKv^Hbs{qt$Ady4u^hD-$ZlgOiNAd55v#C=!XF9s=E? zfVsc#zaP{qCkK@%8hv6A-pKsxyWa=Ax$)F5KLB8M{%IU%)3>f@w!N4JqO{5_rJH>8 z+qVGt)=z)sB_*KK=tvOkI6wHsd&IOMre|Q!5NDiJe1fLh^T8&jGR(~^6Vo#^P`vo# z*ErT1yE%EPW6NCtZYhhUg>U%TRDmI~+p9!XlA-Z2|CY3xMn*LdlV(G5%HM6)%^d~Z z3sS4&q)5_GUinD#$mcgqU9AI<1A2_nURzamdzF})W{<+@PhHe=E+-S2F5d0t$EtNp zTGl->V5|Cpo;~s5`~8K0r)O=eEJg^U2VB*9AJn@cjxX2X!h&aQwgGdmP*k!$Ffhg z;CAgbeJ!l)<|#{ez`;v|J*;pF&Z8v-pxSKq1oM@0hPm|IKpCG}%jDwO6|t;j*FyR5 z!rALDMpt3Cwge~l3CDdh9Md!Yb0R%Xb|)*x$8LMKdH8fy8y#3@jo0fJi)NcW3KY#Y p)n*fADzv=mu;8@${c3O!{SUy{S(OQ=-MRn(002ovPDHLkV1g0X@?HP{ literal 0 HcmV?d00001 diff --git a/hub/public/office-sample-assets/sprites/agent/rectify-report.json b/hub/public/office-sample-assets/sprites/agent/rectify-report.json new file mode 100644 index 0000000..9c5d251 --- /dev/null +++ b/hub/public/office-sample-assets/sprites/agent/rectify-report.json @@ -0,0 +1,822 @@ +{ + "sheet": "cast/sheet-agent-undercut-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/sprites/agent/typing-0.png b/hub/public/office-sample-assets/sprites/agent/typing-0.png new file mode 100644 index 0000000000000000000000000000000000000000..4df44d91f543a387f5fc10709248ca5b75a9b1f2 GIT binary patch literal 3051 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

|7VW-v&qtc+5# za#ujX9UgT8xMUF%Ga(uc-=eutDky>zJ)&>qle_j&;;w()Sh4-V&;4OyQC9wMp$jLE t-+f>U-%l5g7v7uy{UP7{{bpIue*t!8)fyPVRE7Wm002ovPDHLkV1m|2&ieoW literal 0 HcmV?d00001 diff --git a/hub/public/office-sample-assets/sprites/agent/typing-1.png b/hub/public/office-sample-assets/sprites/agent/typing-1.png new file mode 100644 index 0000000000000000000000000000000000000000..a802fa32106161f315a5428bc83058d22e3bc598 GIT binary patch literal 3195 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 literal 0 HcmV?d00001 diff --git a/hub/public/office-sample-assets/sprites/agent/typing-2.png b/hub/public/office-sample-assets/sprites/agent/typing-2.png new file mode 100644 index 0000000000000000000000000000000000000000..ed4e96a7faeb5c34781d2c048c2f920ff0e34a0c GIT binary patch literal 3115 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 FV1nxf5j6k+ literal 0 HcmV?d00001 diff --git a/hub/public/office-sample-assets/sprites/agent/typing-3.png b/hub/public/office-sample-assets/sprites/agent/typing-3.png new file mode 100644 index 0000000000000000000000000000000000000000..1a4512afb2df5c92645cdc6a8f50c68142e03021 GIT binary patch literal 3192 zcmV-;42ScHP)NklA6h-F7wG-4vC+ekF{zDZW1BkIC76Q7p+;q`+Toh&O+l0O4@a(fN4K@ET&{fvkf8`xEIi}eS0u8_YfF@T}a0s!~aR5RomM+%j$ z!;f+Dn~$!l-If{Fp@H!P)g$AU$_?HufDp!;U`OLLUtzyzWaA*MgqTo z?)r(XQ#Z-SZms31KY4&hkGu$W2WqxLMwYEN7WK=81rh%FUw#9?Aq1L)yZ`>%SaO`#Pt2be;yGEmJP;0HgcIlygMEMT{Z(ZS ze)a+BpR&|Oql){9Q$2ehf2)6QIGN#`H|)+xGQ$~=8MZHqV>lX`pm!`y<|~K4 zB?0~|7TN_+_lat`xY-u>ZoRJCtK0*Y18b?P@X-}xCX9sLo8C7=CAPSV$RdW9Q*`OL4l`{6yD zJs(Sr<&+r+bT*Wj`k$XnB2k%{4HC9E(&S4jE09J4p;$=@i|NXn5A6rw+-oNZ%GuOn znK0vfth0-`xd3@NIXG{sSk;Y{n;Ovqenh4~WC~c5&wSFgVqHMV!Rc@@Zy4Cs^cwcc zu@?Za$mGfb+qC0eqVWiN%)st+5SlYnd~SZ8)|M8q3d+mcj?$TnzQz{y;Af0D89J@ zVHKR~?B@B>CM;5t%(HTFUkm&G!zEudH&{}&-2xnp93I% zrN#P%2Y_s$peUJS*c?oH$I&j0reaK31#|QB40Qb#OJ*hjGPr;qKE{Py2}s? zlF+y}CD?lBHUN^*D8h(CIE?1^5uOht13r#C`LF!-U=q*N9M6C0A#!Rr(E6DNak+Ci z-#cu|CUlahCczAq97W-;_I?h4sj*omM@CbJx>8$JL8dBGrj(0`;#-(UL=&mtUm`d3 zzS*-3jde2R8Q|jgPFD8z{I|0~FJ~V3UdY8C{KGMZj~~S|e37s&DJjke;J<$SW0N2% zv5Y*b^Kwu^W<8V1FsjqcVre9nEC0k!n$O3Ta4QwKgrM!I2Sz@z z>vLMa@NnYUqdZ+B_~2WI$d@`ly48ZN1(9({ab6yK|Myrxm{UKjz4?Mbfr+T ziRyxAJOYr)IFW$jHC2RyK|>Oe(n>He&!p!P0B^kbqPb}z&dDSHj5E)T zJ-3j7^L>=nH(}2$q^!QlJd*PA+(dOjrn=n4NGljJjGC~MDI83A$0-3ExCm)p5E&q2 zM5MuF|Jz8#i2#28k*{*k(WBJfb`J{~3T1cPYm)c-P@OKe-*+E+;>yARt=qSw%>{@g z!%TPvx#g4VQN%!iOjQ!q1yh~@iq}-(aJf)~9ccxJJ&TgUGNz`7864;~pZ+t1fk@TlO+6eTo<>{H+1$Q`m!AGd?t18e8Pi6BSB||vNo56VTbdXf88a<5H{`=5 z3JCZ-c>OaZrk5+e9y92D`)wwA2FP={QzSDM>yN$2LEXm9=3+w!Lq{l9+?C~Mvl9#s z^wY4l4NcRy@Xq_(_D2ty7Fo=GX(UKWLCYO?FgY^H*vJ_DogK?p1QttyENyIKyuTY^ z#K|ozBrxqkDo%293&|9Y)T!WbnH4%B4YGw?Zes|H(aJzJG-HuC@1a*PuXFnz2^I%x=Q)`3~ zM;ICf`8Bv51!(aFoVF|mF7>ee3kOXiS3*&-3c)MS{|B|}tB6EY)^BMcAte|(W9NF( zh*fT=NGH~~X}1Ir!UjNj^9BIMy3P}}3hawT6(t*t2!rpPU`@%bI8>Fvi9wUtRghm? zjx$Se;@59bvc^qiQ!}%pqZCwB;7|-khDK6Kz$&*rg_e4m-&&a>cR8eto8ybx&766Q zg8XvAR)eYOVd~n`hOeOLP10g0kuc#yx#2d;Z2vyCoqD^bXT&To5%ib%Z6v`~j?UhC~Nr zqnpBxdZ`P)c{;dUgn~h&;-r4#mKE0csBo1Or(3L~aGgwAB-ZI5Ic+US87CCbaJZca zVZ(uB(m%(PXMo(oLh`2}T(F$2Q`5sVZr$~`3TMkTmdxWk9 zY1-66U*Bobd*&4pJUhzBiL(qUUc6I7D8h~*!#9M`64EMvj?BQPkFQ!nn(y1eiJv^P zV$P~$q#C)V9rqH|Z_Ish%3?)DMPwD1uDZyZ>=&^5&0kTzgX+3gs>*8_{h<4Xkb=wZ zU?(PeuBvLjS|$S{m*`2JDY-H#XM7=Md>vGkuk1)Z4z78lxCE)hnH-y;)Y33{X1^@s6*FMFU0004}NklAy+Ci^qx1;f)ni0<*Yz`F;-P`{pnV21HqcJoaHq!ShFKHl_ulEI~)7XLx&R;mh}t-YY2i+lzCI zsju`Y2f;0}i^mY8aZi;nf_rnNDB|IQyi;CC004p*3Z)Yz#%GJTSWIGqM)Di{4~g&5 z(x%y(KT*Pf$<@k0-YG9_dZ=9n|DHc$5Zt0-rAkqjAW69FlXb;Pl?1ma7-yc%O$aLw z#>pHw)M?bUQKV-~%a$Vv+C&5ZI*s~|8d4lP@1{I9jgP6%l-o(=?3Emw%Gt^}0!%h6 zxJBKj3jj!FGhmT5BSE+6qVF~V0CCzv58Vlg#IXMe0B8-`W(hP@jy>u!!0?^``u4-H z5W}b6QTjB;3T~0-kC=Qf5<58n(-&=oca7L+`>?6D?X0HG=o3@o&?#ZidO@%4BAK?q zH)4pz*Kt?74r-*jxg-+Fw2fZdHKhWgNSMI%lH0pEdVRjv;)|BAA(h^rTf8#SDuv(s WzN!l|)GP1+0000J+Xj4yj{Q>Sm4J3&u%B)4J*6n7Z)VQSZ|2N75B|%)CdO2%F_rq)@|P+y zQlaA`U5t-(k&z0Gf1SM-{|Kmn-8$IWO*Zw~;&gV$-#b~13PIsp}|Z$8@SC(sC1B#alq#q1OfxOPRqp{xC& z(Uewj`s`U|pG^}vNCGZ5a4DH^DR5kuvP&@q6yGBviPH*?udbnm_LIjE$Dp2N`pI)L zne_lPw;cjtYHXa*+-*9}oljM~k_14inpg)mRYg5ZZt#KtxWUGE(|N=>frv1C<4Pjv zXzun8C0Wb$jS) ze+0Gj;1K{09&P_F6!^21?d~H#F$q9*)wt zzf+|xUCI7<^gaO3@7)EUVNWBCO-*d7t4ja{MW|{B6cku`0kYkF(V%bJ*9O2`eu8XI z_p(p`>RF8IB#vV>lI`vb*W#r55ynnIxsJoJGo5%S4p9{znz%g>f{HVJ2T5Dv*WOFV_rJPUiEEp?U)?ZLI)IJ$MK}^Wj#!LV>AA{!KRE{~6BZ zCsIMIEy&M);CIQ0(0s&ycDzD?{Mf@VPXHH4*eS30L0retqUZg>-oV@Wr&7CRx7ye< qv+KZK_8i`ih|AZ-FA>~Qr@jFIeSr;*0!q680000g+{5;b0{;1w>zdlF-z-JWCUjK{xI~^Q<>_cE_Da4R;Qd+GgQwhq8N6vGFO`DLyEL9gg-FvAWq>5Q+VJUn@G1qDpPPj}>Z7|uK3UP?P z{Jfoy6*gz`mL}NLQbrVWFp`3&NnuRDQ*2D$#ArBlq9JhlH46#9|Me^%BLMKe{{=wi zP*li_34H$I7RP)>5htFr2WXBN8E<;GkbU-EyM`P!2 ztKT%)w$qWy>gqF!Zx-g9nXo~1D1Ok&pXcW$G|Ftwla`y*SW7*=oU5pez5T=Nflwe3 zcH-t38i)ZjjPDL!9btGjVT#8wVxDR#m`N@7_7Bl)H30yO5X4D>!`^XWuC}>_aG{Vm z136!*p(@U4l1mKQy%DV8D!l9U?S5+$(>N)v^ zYFk48U19QSb>@nyKx7*ig+}32I+^7~dpr|qn9nF04d02NIC)b&C+wJ!@Vd(YuBiYVq6s|p$o-&tZR2qmSG zvmtC!FhW3>nf2Z7R_?^KP;hE6*zwqGdu7tPD5`oeagrd3&2wHJ{g*$JPlK=U8O>Et zR!iZnOmPlCVclh*?Atb*?A%z(lM)gq38I+i8Ltd^fsEwvn4!nuHEWNIa~?h@hsQwuq!7NHHY&GpZD8uSO~e=JX! z@7?Jjig}iPxiDw)mXws|jjruV%EbdhRliI^NaUP*x|Z_N|(OmN@r5^fgaKS&(;?CO1BAYNwVwGDNB* zAS84eC57&n38to1gE8FS>E!K6jq=*+6>U*8I>Y9AJG&Pr6xZ5UONrW`)?xQ{e#GW_ z8x6*=x!$f-$?tZzu-o0r);9uduD7wWvRt?;&?HyYE^7w$>Ni;}wQQbeb}0_4Zb~M4 zsSB-Q)gtV@w$0hfjVh?Y-Lq(RRq{-|>LlHXQ(0YHFY^Uv^ z%_U*IPcO08TDhg^?w~G$*2Fo;fi|^V8w2*rpsZMxQMoyrN1Fz=a>x%_2%ByMZ6%Lz aV*dm1B#MdQ*b+Vf0000OB6<-3rz24G4>IS;0j6OY@zqpxBu8jG|m>1 z7O{Oaa?(*D>8OxL>hb+&A*si=Mov0cC1gyVzK|mU{K$|lT_Hb%<;DO2NCzOH$knXi zNR)*I0F2F~@x9;y?^1XHUy(MFsZdu`NIEJc9Tk#}3Q0$Wq@zNo==;}7bfLD-`b%-! zkt+)eNA@18V{{tu)+>vHKnEbe-I>|%%sFS~ z`$o#-ZU}&m+!#h&p%O?WWkI1(f=8ZwPy1>q{NEpZ*Ad{U>|j zi-w2T+MxIP*I4@PJ8fe{Z94+b``&kP%Ey>pod9h>fAaRXfBx>B#a|S+?!FMSr+`!g04p~_ENuZ$bjsB4 zeu`{an36z};s{aYETdA-MXW>ur@V_fF$ItJCc)MZDD59IE6#}{5r%;s5w}fJ^&8$ zkG>QjJ$yO(-}8-E3EU$93grri@4Zj|+utWC3s!F4COBADy72` z!f}vH6WOxpl4B9jvLrD&{S}T598`{7yy5|pYD7VLh6kw+4Nw;W>5c>qVCSQI$jKx@ zrGlJHq7S`DT*1V?A8d1r8ap1?BY)3Ny#)>UyEUs z4^infBBZM7V_lu0=DHk-0K+gi*nUc*;DDwRoYaX;jNxtUlF$;^JF5{x#7w%cz7on& zs?b<_LMAsvv9N)rN(du`Iy#I~ETLplCox33p*B4QS#tEuEfu^H&=vb5E=#orb1kt48o%;ZhbbWKP z?B_Qzohl6UVlS;n{awTT6qh!*@Y*Crr%cA|;^`-gp%NWBL8StOySljcgtX?-IWTg{ z5D6@EH>B|C;xi-W8p$&jz#>r+=D4t88>3Aoh*|pJ&h!ZaY)7k_r zF#5z8v9yKUs8c#D5hN3*9tx^HXiA8Bay*JL{V##-&^CyU6Z;-j8rrv;|8@tB_BLH6^Tcqet# zWDgBM^gt`JlzY->7bI)1T4-`s4(diT{=uoqV% zAgOeU+UG5Bl8&nSsH#t3ml1A_{rqO6ckh+U5o@2{{0I8PShN7OLnqL5`Y(@RFRsv7 zdxGEeQp*Bb)`TvTz0V#+mS};w@Y-bfys<^+z(|uJ5w1{jOV|%=h?&U1_?t82AMa3e zUE=kCsxG5;=+qys(tT})pw=|}@+!Mqd8CwuT&WOfI(=6zQx^fQ>ypqClvmcm7)A`W zsf6pg?0puV?#kd`yJP?(^Vd06I-=*wu}62No1jrAAq%!|-j4c;w?3fnn>WG_N)mx> zqogd9+z1L#7XigP_mPuHT8{Bf>U1}EFMzkP8yVc+++@!!wF7FkPXDA0n5^j>1IqMt z+qnHho1n0bqK0`=lx);Y3cpw)JA8oyO-F750noB0uIu7$>|#xhBN;J*llo~2f;NK# zu=DZ9tle8+_r1T<>7PXJcb8YbI24*7ebqp=ETq9fd@n%Bq*%HA5wcq#C~Tu;&F~0t zYxrI$vo6VuO}~t)`cQLF1_v><1W|GoMvB0;ku8hy`MEaPB2asK&jjT!tuy}hS6MHV zp4s0v_Q8X;=l-cz+1<)VHCUTu<8RG#bPy)Iz&%1X5>ekl@!;Gba$I5U-U3q7n9pl1 z*%dVurz*+e83;&KFOWWUNv zb6fAce~Y9nNXqBe?53uMKfo3;vXO{ncwRG%a53Zab9DChM(>5Fo=OI^Qyw!}ySIRB zB-qbyvUmRhW>+WWrH9yeJ_Ii;8{+kVxR8_Oe149V+aE;-aqrADJMZ7ZiYH$j5WtmR z_T2dD!nbn2|IP1y4RG_R6J>Ca(b=hpq2)jStzA+j9@W+U#tP|| zhF%Pi)X^_4ygC1+_toX)b8n)VOIL+mt+d_S&dNGEJH_&!{)ja^hE( 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()