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 0000000..7749db0 Binary files /dev/null and b/hub/public/office-sample-assets/sprites/agent/idle-anchor.png differ 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 0000000..4df44d9 Binary files /dev/null and b/hub/public/office-sample-assets/sprites/agent/typing-0.png differ 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 0000000..a802fa3 Binary files /dev/null and b/hub/public/office-sample-assets/sprites/agent/typing-1.png differ 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 0000000..ed4e96a Binary files /dev/null and b/hub/public/office-sample-assets/sprites/agent/typing-2.png differ 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 0000000..1a4512a Binary files /dev/null and b/hub/public/office-sample-assets/sprites/agent/typing-3.png differ diff --git a/hub/public/office-sample-assets/sprites/desk/chair.png b/hub/public/office-sample-assets/sprites/desk/chair.png new file mode 100644 index 0000000..20e65cc Binary files /dev/null and b/hub/public/office-sample-assets/sprites/desk/chair.png differ diff --git a/hub/public/office-sample-assets/sprites/desk/desk-tidy.png b/hub/public/office-sample-assets/sprites/desk/desk-tidy.png new file mode 100644 index 0000000..05930bf Binary files /dev/null and b/hub/public/office-sample-assets/sprites/desk/desk-tidy.png differ diff --git a/hub/public/office-sample-assets/sprites/desk/rectify-report.json b/hub/public/office-sample-assets/sprites/desk/rectify-report.json new file mode 100644 index 0000000..350dacc --- /dev/null +++ b/hub/public/office-sample-assets/sprites/desk/rectify-report.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/sprites/tiles/floor-wood.png b/hub/public/office-sample-assets/sprites/tiles/floor-wood.png new file mode 100644 index 0000000..7bc9584 Binary files /dev/null and b/hub/public/office-sample-assets/sprites/tiles/floor-wood.png differ diff --git a/hub/public/office-sample-assets/sprites/tiles/rectify-report.json b/hub/public/office-sample-assets/sprites/tiles/rectify-report.json new file mode 100644 index 0000000..b86cae7 --- /dev/null +++ b/hub/public/office-sample-assets/sprites/tiles/rectify-report.json @@ -0,0 +1,187 @@ +{ + "sheet": "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/sprites/tiles/wall-cracked.png b/hub/public/office-sample-assets/sprites/tiles/wall-cracked.png new file mode 100644 index 0000000..8bb0e72 Binary files /dev/null and b/hub/public/office-sample-assets/sprites/tiles/wall-cracked.png differ diff --git a/hub/public/office-sample-assets/sprites/window/rectify-report.json b/hub/public/office-sample-assets/sprites/window/rectify-report.json new file mode 100644 index 0000000..77551af --- /dev/null +++ b/hub/public/office-sample-assets/sprites/window/rectify-report.json @@ -0,0 +1,174 @@ +{ + "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": [ + [ + 56, + 0, + 55 + ], + [ + 58, + 0, + 66 + ], + [ + 56, + 0, + 72 + ], + [ + 123, + 3, + 123 + ] + ], + "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/window/window-blue-night.png b/hub/public/office-sample-assets/sprites/window/window-blue-night.png new file mode 100644 index 0000000..7185ac0 Binary files /dev/null and b/hub/public/office-sample-assets/sprites/window/window-blue-night.png differ 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()