reach atlas db / names / systems-done / check / verify / commit-and-sync / update-field / flatness. Eight scripts retired, five of them bash. verify reproduces the original exactly: 2 errors across 301 proposals, exit 1. The binary has more verbs than its wrapper documented. The bash usage text listed four; atlas-commit-and-sync calls four more it never mentioned. All eight are declared so reach atlas --help is a complete index, and unknown verbs are still forwarded — a hand-maintained list falls behind the binary it describes, so rejecting on it would break the day someone adds a subcommand. commit-and-sync now stages by default and commits only with --commit. Nothing else in reach writes to git history, and committing as a side effect of "sync" is a different risk class from writing a file; the default prints the message it would use, leaving the decision where it was. Two real bugs found in that script while porting it. It ran atlas-verify and never checked the exit code, so a proposal that FAILED verification was still wiped, committed and synced — bad data in systems.db is far harder to undo than a failed command, and it now refuses. And it hardcoded a pinned "Co-Authored-By: Claude Opus 4.6" into every atlas commit, which the git-commit skill names as the root cause of attribution drift. update-field gains two guards the original lacked. Its field→table map lived inside a bash heredoc string where nothing could check it, and an unknown field produced an UPDATE against a table of None; it now names the nine accepted fields. And it checks rowcount, so a system_id that does not exist is a failure rather than a silent no-op reported as success. The three Python scripts moved with the usual treatment — prints to console events, argparse replaced by typed functions, __file__ roots to config.repo_root(). No root bug this time: checked before moving rather than after, three domains running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
4.1 KiB
Python
Executable File
121 lines
4.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Measure how much STRUCTURE an Atlas capture carries, per rung.
|
|
|
|
"Flat" is the word T-1213/D-258 use for the defect the descent ladder exposes,
|
|
but a word cannot be an acceptance gate and an eyeball cannot be a regression
|
|
test. This turns the ladder into numbers.
|
|
|
|
WHY NOT JUST COUNT COLOURS
|
|
--------------------------
|
|
Because the count goes the wrong way. Measured on the 2026-08-16 cold ladder:
|
|
|
|
rung distinct lum p1-p99
|
|
Global 1581 145.69
|
|
Region 2923 33.59
|
|
District 53 13.72
|
|
Quarter 46 11.01
|
|
|
|
Region carries almost TWICE Global's distinct-colour count while holding a
|
|
quarter of its structure — that is the dither/stipple pass (T-1194) adding colour
|
|
noise, not information. A metric that rewards speckle would have called the
|
|
flattest rung the richest. So the headline number here is the 1st-99th percentile
|
|
luminance spread, which ignores per-pixel noise and measures the large-scale
|
|
variation a map is actually read for; distinct-count is reported alongside
|
|
precisely so the two can be seen disagreeing.
|
|
|
|
Usage:
|
|
tooling/atlas-flatness .cache/screenshots/atlas_GJ820Bc_land_Region.png [...]
|
|
tooling/atlas-flatness --ladder # the standard descent ladder
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from tooling.core import config, console
|
|
from tooling.core.errors import ReachError
|
|
|
|
try:
|
|
from PIL import Image
|
|
except ImportError: # pragma: no cover - environment guard
|
|
console.event("atlas-flatness: Pillow not installed", level="error")
|
|
sys.exit(2)
|
|
|
|
REPO_ROOT = config.repo_root()
|
|
SHOTS = REPO_ROOT / ".cache" / "screenshots"
|
|
|
|
# The standard descent ladder: one body, one world point, once per rung.
|
|
LADDER = [
|
|
("Global", "atlas_GJ820Bc_Global.png"),
|
|
("Region", "atlas_GJ820Bc_land_Region.png"),
|
|
("District", "atlas_GJ820Bc_land_District.png"),
|
|
("Quarter", "atlas_GJ820Bc_land_Quarter.png"),
|
|
]
|
|
|
|
# Terrain-only crop. The header/legend panels sit top-left and the overlay chips
|
|
# top-right; both are flat UI fills that would drag every statistic toward
|
|
# whatever the panel colour happens to be, and they do not vary with the rung.
|
|
CROP_LEFT = 700
|
|
CROP_TOP = 120
|
|
|
|
|
|
def luminance(px) -> float:
|
|
r, g, b = px[:3]
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
|
|
|
|
def measure(path: Path) -> dict:
|
|
img = Image.open(path).convert("RGB")
|
|
w, h = img.size
|
|
if w <= CROP_LEFT or h <= CROP_TOP:
|
|
raise ValueError(f"{path.name} is {w}x{h}, smaller than the UI crop")
|
|
img = img.crop((CROP_LEFT, CROP_TOP, w, h))
|
|
# get_flattened_data() is the Pillow 12+ name; getdata() is deprecated there
|
|
# and removed in 14, but is all that older Pillows have.
|
|
reader = getattr(img, "get_flattened_data", None) or img.getdata
|
|
pixels = list(reader())
|
|
|
|
stats = {}
|
|
for name, ch in zip("RGB", zip(*pixels)):
|
|
n = len(ch)
|
|
mean = sum(ch) / n
|
|
stats[name] = (sum((v - mean) ** 2 for v in ch) / n) ** 0.5
|
|
|
|
lums = sorted(luminance(p) for p in pixels)
|
|
n = len(lums)
|
|
return {
|
|
"distinct": len(set(pixels)),
|
|
"std": stats,
|
|
"spread": lums[int(n * 0.99)] - lums[int(n * 0.01)],
|
|
}
|
|
|
|
|
|
def report(images: list[Path], ladder: bool = False) -> int:
|
|
"""Measure capture structure per rung. Returns 0, or 1 if any file is missing."""
|
|
targets = []
|
|
if ladder:
|
|
targets = [(label, SHOTS / name) for label, name in LADDER]
|
|
targets += [(p.stem, p) for p in images]
|
|
|
|
if not targets:
|
|
raise ReachError(
|
|
"no captures given",
|
|
fix="pass image paths, or --ladder to measure the standard descent",
|
|
exit_code=2,
|
|
)
|
|
|
|
console.out(
|
|
f"{'rung':22} {'distinct':>9} {'R std':>7} {'G std':>7} {'B std':>7} {'lum p1-p99':>11}"
|
|
)
|
|
missing = 0
|
|
for label, path in targets:
|
|
if not path.exists():
|
|
console.out(f"{label:22} MISSING {path}")
|
|
missing += 1
|
|
continue
|
|
m = measure(path)
|
|
r, g, b = (m["std"][c] for c in "RGB")
|
|
console.out(
|
|
f"{label:22} {m['distinct']:>9} {r:>7.2f} {g:>7.2f} {b:>7.2f} {m['spread']:>11.2f}"
|
|
)
|
|
return 1 if missing else 0
|