imgtools.structural() marked a cell occupied when its grey exceeded an absolute 10/255, and the WardenOS page background is grey 16: every cell of every region read occupied and no structural check could ever fail (#19). Occupancy is now grey deviating from the crop's own median by more than DEVIATION_THRESHOLD, or edge energy above EDGE_THRESHOLD. Measured on real captures: a switch knob left/right differs in 240 of 256 cells (was 0), a dark card reads its icon and text and nothing else. Every committed reference is recaptured with flare-edge tools/flow-run-all.sh --capture. qmp.py to_axis() truncated the pixel-to-axis conversion and LVGL's evdev calibration truncates on the way back, so many pixels landed one short (130 -> 5924 -> 129) and a tap could miss the control hit had just confirmed at that pixel (#21). It now rounds up to the smallest axis value that truncates to the requested pixel; test_qmp_drive.py asserts the round trip for every pixel at three panel sizes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
472 lines
17 KiB
Python
472 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""PPM loading, cropping and image comparison for UI flow region asserts.
|
|
|
|
Standalone (no VM, no other module in this repo needed): a screendump is
|
|
QEMU's own P6 PPM file, and everything below works straight off that file
|
|
plus a JSON reference. Pure Python plus Pillow only.
|
|
|
|
imgtools.py compare REFS NAME SHOT.ppm
|
|
imgtools.py capture REFS NAME SHOT.ppm X Y W H TOL # TOL: exact|loose|structural
|
|
imgtools.py hash SHOT.ppm
|
|
imgtools.py selftest
|
|
|
|
`compare` and `capture` read/write a reference file: JSON mapping a region
|
|
name to {"x","y","w","h","tolerance","phash","structural"}. `capture` always
|
|
fills in both hashes so an existing entry's tolerance class can be changed
|
|
later without re-shooting the region.
|
|
|
|
Public API (imported directly by the driver, not just via the CLI above):
|
|
|
|
load_ppm(path) -> (w, h, rgb_bytes)
|
|
crop(img, x, y, w, h) -> img
|
|
phash(img) -> int 8x8 DCT of a 32x32 grey downscale, 64-bit
|
|
structural(img) -> bytes 16x16 edge/nonblack occupancy mask, 32 bytes
|
|
compare(ref_entry, cur_img, tolerance) -> (ok, detail)
|
|
|
|
`img` is always the (w, h, rgb_bytes) triple load_ppm/crop return -- there is
|
|
no separate image type. rgb_bytes is tightly packed row-major RGB, 3 bytes
|
|
per pixel, no padding, matching both PPM P6 and PIL's "RGB" raw layout.
|
|
"""
|
|
import math
|
|
import os
|
|
import sys
|
|
import json
|
|
|
|
from PIL import Image, ImageFilter
|
|
|
|
# occupancy thresholds for structural(): a downsampled cell counts as
|
|
# "occupied" if it is meaningfully brighter than black, or sits on an edge.
|
|
# Both are 0..255 greyscale/edge-magnitude averages over the cell.
|
|
# structural(): a cell is occupied when its grey is this far from the crop's
|
|
# median (its background) or its FIND_EDGES energy exceeds the edge threshold.
|
|
# Validated on real captures: a switch knob left/right differs in 240/256
|
|
# cells, a dark card's icon and text stand out from its (7,13,29) ground.
|
|
DEVIATION_THRESHOLD = 28
|
|
EDGE_THRESHOLD = 24
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PPM
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def load_ppm(path):
|
|
"""Read a binary PPM (P6), return (w, h, rgb_bytes).
|
|
|
|
QEMU's screendump writes a comment-free header ("P6\\nW H\\n255\\n"
|
|
then raw bytes) but the token reader here handles the general P6
|
|
grammar -- whitespace runs and '#' comments -- since that costs
|
|
nothing extra and means any other producer of P6 files just works.
|
|
"""
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
|
|
def skip_ws_comments(p):
|
|
while p < len(data):
|
|
c = data[p]
|
|
if c in b" \t\r\n":
|
|
p += 1
|
|
elif c == ord("#"):
|
|
while p < len(data) and data[p] != ord("\n"):
|
|
p += 1
|
|
else:
|
|
break
|
|
return p
|
|
|
|
def read_token(p):
|
|
p = skip_ws_comments(p)
|
|
start = p
|
|
while p < len(data) and data[p] not in b" \t\r\n":
|
|
p += 1
|
|
return data[start:p], p
|
|
|
|
pos = 0
|
|
magic, pos = read_token(pos)
|
|
if magic != b"P6":
|
|
raise ValueError(f"{path}: not a P6 PPM (magic={magic!r})")
|
|
w_tok, pos = read_token(pos)
|
|
h_tok, pos = read_token(pos)
|
|
maxval_tok, pos = read_token(pos)
|
|
w, h, maxval = int(w_tok), int(h_tok), int(maxval_tok)
|
|
if maxval != 255:
|
|
raise ValueError(f"{path}: unsupported PPM maxval {maxval} (only 255 handled)")
|
|
# exactly one whitespace byte separates the header from the binary data
|
|
pos += 1
|
|
need = w * h * 3
|
|
pixels = data[pos:pos + need]
|
|
if len(pixels) != need:
|
|
raise ValueError(f"{path}: truncated PPM, want {need} bytes got {len(pixels)}")
|
|
return w, h, pixels
|
|
|
|
|
|
def crop(img, x, y, w, h):
|
|
"""Crop (W,H,rgb) to the x,y,w,h box, return a new (w,h,rgb) image."""
|
|
width, height, data = img
|
|
if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > width or y + h > height:
|
|
raise ValueError(f"crop box {x},{y},{w}x{h} outside image {width}x{height}")
|
|
row_bytes = w * 3
|
|
out = bytearray(h * row_bytes)
|
|
for row in range(h):
|
|
src_off = ((y + row) * width + x) * 3
|
|
dst_off = row * row_bytes
|
|
out[dst_off:dst_off + row_bytes] = data[src_off:src_off + row_bytes]
|
|
return w, h, bytes(out)
|
|
|
|
|
|
def _to_pil(img):
|
|
w, h, data = img
|
|
return Image.frombytes("RGB", (w, h), data)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# perceptual hash (phash): 8x8 DCT of a 32x32 grey downscale
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _dct_basis(n):
|
|
"""n x n DCT-II basis matrix. Unnormalised -- fine since phash only
|
|
thresholds coefficients against each other, never compares magnitudes
|
|
across images."""
|
|
return [[math.cos(math.pi / n * (col + 0.5) * row) for col in range(n)] for row in range(n)]
|
|
|
|
|
|
_DCT32 = _dct_basis(32)
|
|
|
|
|
|
def _dct2d_32(rows):
|
|
"""2D DCT-II of a 32x32 list-of-lists via two separable 1D passes."""
|
|
n = 32
|
|
# columns first: tmp[u][x] = DCT of column x at frequency u
|
|
tmp = [[0.0] * n for _ in range(n)]
|
|
for u in range(n):
|
|
b = _DCT32[u]
|
|
for x in range(n):
|
|
s = 0.0
|
|
for y in range(n):
|
|
s += rows[y][x] * b[y]
|
|
tmp[u][x] = s
|
|
# then rows: out[u][v] = DCT of tmp's row u at frequency v
|
|
out = [[0.0] * n for _ in range(n)]
|
|
for u in range(n):
|
|
row = tmp[u]
|
|
for v in range(n):
|
|
b = _DCT32[v]
|
|
s = 0.0
|
|
for x in range(n):
|
|
s += row[x] * b[x]
|
|
out[u][v] = s
|
|
return out
|
|
|
|
|
|
def phash(img):
|
|
"""8x8-DCT perceptual hash of a 32x32 greyscale downscale, as a 64-bit
|
|
int. Classic pHash recipe: downscale, DCT, keep the low-frequency 8x8
|
|
corner, threshold each coefficient against the block's mean (DC term
|
|
excluded from the mean -- it is just overall brightness and would bias
|
|
every bit the same way)."""
|
|
small = _to_pil(img).convert("L").resize((32, 32), Image.LANCZOS)
|
|
px = small.load()
|
|
rows = [[px[x, y] for x in range(32)] for y in range(32)]
|
|
coeffs = _dct2d_32(rows)
|
|
block = [coeffs[u][v] for u in range(8) for v in range(8)]
|
|
mean = sum(block[1:]) / (len(block) - 1)
|
|
bits = 0
|
|
for c in block:
|
|
bits = (bits << 1) | (1 if c > mean else 0)
|
|
return bits
|
|
|
|
|
|
def hamming(a, b):
|
|
"""Bit-differences between two phash ints."""
|
|
return bin(a ^ b).count("1")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# structural occupancy hash: 16x16 edge/nonblack mask
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def structural(img):
|
|
"""16x16 binary occupancy mask as 32 bytes (256 bits, MSB first,
|
|
row-major). A cell is "occupied" if it carries ink relative to the crop's
|
|
OWN background -- its grey deviates from the crop's median by more than
|
|
DEVIATION_THRESHOLD -- or sits on an edge, so the mask is robust to a
|
|
recolour (still occupied) but sensitive to a shape disappearing (goes
|
|
from occupied to empty).
|
|
|
|
Relative to the median, not to black: the WardenOS page background is
|
|
(7,13,29), grey 16, and an absolute non-black test read every cell of
|
|
every region as occupied, so no structural check could ever fail
|
|
(SDK #19, flare-edge #156). Measured on real captures with this rule: a
|
|
Bluetooth switch off/on differs in 240 of 256 cells, a dark card reads
|
|
its icon and text and nothing else."""
|
|
grey = _to_pil(img).convert("L")
|
|
edges = grey.filter(ImageFilter.FIND_EDGES)
|
|
grey_small = grey.resize((16, 16), Image.BOX)
|
|
edge_small = edges.resize((16, 16), Image.BOX)
|
|
median = _median_grey(grey)
|
|
gpx, epx = grey_small.load(), edge_small.load()
|
|
bits = bytearray(32)
|
|
idx = 0
|
|
for y in range(16):
|
|
for x in range(16):
|
|
occupied = abs(gpx[x, y] - median) > DEVIATION_THRESHOLD or epx[x, y] > EDGE_THRESHOLD
|
|
if occupied:
|
|
bits[idx // 8] |= 1 << (7 - (idx % 8))
|
|
idx += 1
|
|
return bytes(bits)
|
|
|
|
|
|
def _median_grey(grey):
|
|
"""The crop's dominant luminance: the background of a card, the fill of a
|
|
switch, whatever most of the pixels are."""
|
|
hist = grey.histogram()
|
|
total = sum(hist)
|
|
acc = 0
|
|
for value, count in enumerate(hist):
|
|
acc += count
|
|
if acc * 2 >= total:
|
|
return value
|
|
return 0
|
|
|
|
|
|
def _structural_diff(a, b):
|
|
"""Count of differing bits between two 32-byte occupancy masks."""
|
|
return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compare
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def compare(ref_entry, cur_img, tolerance):
|
|
"""Compare cur_img (already cropped to the reference's box) against
|
|
ref_entry under the given tolerance class. Returns (ok, detail); detail
|
|
explains the verdict either way, for results.jsonl.
|
|
|
|
exact: phash hamming distance == 0
|
|
loose: phash hamming distance <= 6
|
|
structural: occupancy mask bytes equal (no distance, no tolerance)
|
|
"""
|
|
if tolerance in ("exact", "loose"):
|
|
if "phash" not in ref_entry:
|
|
return False, "reference missing 'phash' field"
|
|
try:
|
|
ref_hash = int(ref_entry["phash"], 16)
|
|
except (TypeError, ValueError):
|
|
return False, f"reference 'phash' is not valid hex: {ref_entry.get('phash')!r}"
|
|
cur_hash = phash(cur_img)
|
|
dist = hamming(ref_hash, cur_hash)
|
|
limit = 0 if tolerance == "exact" else 6
|
|
ok = dist <= limit
|
|
detail = f"phash hamming={dist} limit={limit} ref={ref_hash:016x} cur={cur_hash:016x}"
|
|
return ok, detail
|
|
|
|
if tolerance == "structural":
|
|
if "structural" not in ref_entry:
|
|
return False, "reference missing 'structural' field"
|
|
try:
|
|
ref_bits = bytes.fromhex(ref_entry["structural"])
|
|
except (TypeError, ValueError):
|
|
return False, f"reference 'structural' is not valid hex: {ref_entry.get('structural')!r}"
|
|
cur_bits = structural(cur_img)
|
|
ok = ref_bits == cur_bits
|
|
if ok:
|
|
detail = "structural mask matches (0/256 cells differ)"
|
|
else:
|
|
diff = _structural_diff(ref_bits, cur_bits)
|
|
detail = f"structural mask differs in {diff}/256 cells"
|
|
return ok, detail
|
|
|
|
return False, f"unknown tolerance class: {tolerance!r}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_refs(path):
|
|
if not os.path.exists(path):
|
|
return {}
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _save_refs(path, refs):
|
|
with open(path, "w") as f:
|
|
json.dump(refs, f, indent=2, sort_keys=True)
|
|
f.write("\n")
|
|
|
|
|
|
def cmd_compare(argv):
|
|
if len(argv) != 3:
|
|
print("usage: imgtools.py compare REFS NAME SHOT.ppm", file=sys.stderr)
|
|
return 2
|
|
refs_path, name, shot_path = argv
|
|
refs = _load_refs(refs_path)
|
|
if name not in refs:
|
|
print(f"FAIL {name}: no such reference (run capture first)")
|
|
return 1
|
|
entry = refs[name]
|
|
for key in ("x", "y", "w", "h", "tolerance"):
|
|
if key not in entry:
|
|
print(f"FAIL {name}: reference entry missing '{key}'")
|
|
return 1
|
|
w, h, data = load_ppm(shot_path)
|
|
cropped = crop((w, h, data), entry["x"], entry["y"], entry["w"], entry["h"])
|
|
ok, detail = compare(entry, cropped, entry["tolerance"])
|
|
print(f"{'PASS' if ok else 'FAIL'} {name}: {detail}")
|
|
return 0 if ok else 1
|
|
|
|
|
|
def cmd_capture(argv):
|
|
if len(argv) != 8:
|
|
print("usage: imgtools.py capture REFS NAME SHOT.ppm X Y W H TOL", file=sys.stderr)
|
|
return 2
|
|
refs_path, name, shot_path, x, y, w, h, tol = argv
|
|
if tol not in ("exact", "loose", "structural"):
|
|
print(f"usage: TOL must be exact|loose|structural, got {tol!r}", file=sys.stderr)
|
|
return 2
|
|
x, y, w, h = int(x), int(y), int(w), int(h)
|
|
iw, ih, data = load_ppm(shot_path)
|
|
cropped = crop((iw, ih, data), x, y, w, h)
|
|
entry = {
|
|
"x": x, "y": y, "w": w, "h": h,
|
|
"tolerance": tol,
|
|
"phash": f"{phash(cropped):016x}",
|
|
"structural": structural(cropped).hex(),
|
|
}
|
|
refs = _load_refs(refs_path)
|
|
refs[name] = entry
|
|
_save_refs(refs_path, refs)
|
|
print(f"captured {name}: {entry}")
|
|
return 0
|
|
|
|
|
|
def cmd_hash(argv):
|
|
if len(argv) != 1:
|
|
print("usage: imgtools.py hash SHOT.ppm", file=sys.stderr)
|
|
return 2
|
|
w, h, data = load_ppm(argv[0])
|
|
img = (w, h, data)
|
|
print(f"{w}x{h} phash={phash(img):016x} structural={structural(img).hex()}")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# selftest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_test_rgb(w, h, colour, shift=(0, 0)):
|
|
"""Black canvas with a coloured square near one corner, used only by
|
|
the self-test. colour=None means no square at all (removed shape)."""
|
|
from PIL import ImageDraw
|
|
img = Image.new("RGB", (w, h), (0, 0, 0))
|
|
if colour is not None:
|
|
draw = ImageDraw.Draw(img)
|
|
ox, oy = shift
|
|
x0, y0 = 8 + ox, 8 + oy
|
|
draw.rectangle([x0, y0, x0 + 20, y0 + 20], fill=colour)
|
|
return img.tobytes()
|
|
|
|
|
|
def _write_ppm(path, w, h, rgb_bytes):
|
|
with open(path, "wb") as f:
|
|
f.write(f"P6\n{w} {h}\n255\n".encode("ascii"))
|
|
f.write(rgb_bytes)
|
|
|
|
|
|
def selftest():
|
|
import tempfile
|
|
|
|
w, h = 128, 128
|
|
failures = []
|
|
|
|
def check(label, cond):
|
|
print(f"[{'ok' if cond else 'FAIL'}] {label}")
|
|
if not cond:
|
|
failures.append(label)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
refs_path = os.path.join(tmp, "refs.json")
|
|
|
|
base_path = os.path.join(tmp, "base.ppm")
|
|
_write_ppm(base_path, w, h, _make_test_rgb(w, h, (220, 20, 20)))
|
|
|
|
# capture the same corner box under all three tolerance classes
|
|
for tol in ("exact", "loose", "structural"):
|
|
rc = main(["capture", refs_path, f"corner-{tol}", base_path, "4", "4", "32", "32", tol])
|
|
check(f"capture corner-{tol} succeeds", rc == 0)
|
|
|
|
# load_ppm/crop sanity: the drawn pixel lands where expected
|
|
bw, bh, bdata = load_ppm(base_path)
|
|
check("load_ppm reads back the written size", (bw, bh) == (w, h))
|
|
cropped = crop((bw, bh, bdata), 4, 4, 32, 32)
|
|
px_off = ((8 - 4) * 32 + (8 - 4)) * 3
|
|
check("crop keeps the drawn pixel in place",
|
|
cropped[2][px_off:px_off + 3] == bytes((220, 20, 20)))
|
|
|
|
# identical shot: exact must pass
|
|
same_path = os.path.join(tmp, "same.ppm")
|
|
_write_ppm(same_path, w, h, _make_test_rgb(w, h, (220, 20, 20)))
|
|
rc = main(["compare", refs_path, "corner-exact", same_path])
|
|
check("exact passes for an identical shot", rc == 0)
|
|
|
|
# small shift: exact must fail, loose must still pass
|
|
shift_path = os.path.join(tmp, "shift.ppm")
|
|
_write_ppm(shift_path, w, h, _make_test_rgb(w, h, (220, 20, 20), shift=(1, 1)))
|
|
rc = main(["compare", refs_path, "corner-exact", shift_path])
|
|
check("exact fails for a slight shift", rc == 1)
|
|
rc = main(["compare", refs_path, "corner-loose", shift_path])
|
|
check("loose passes for a slight shift", rc == 0)
|
|
|
|
# colour change, same footprint: structural must still pass
|
|
colour_path = os.path.join(tmp, "colour.ppm")
|
|
_write_ppm(colour_path, w, h, _make_test_rgb(w, h, (20, 20, 220)))
|
|
rc = main(["compare", refs_path, "corner-structural", colour_path])
|
|
check("structural passes for a colour change", rc == 0)
|
|
|
|
# shape removed entirely: structural must fail
|
|
removed_path = os.path.join(tmp, "removed.ppm")
|
|
_write_ppm(removed_path, w, h, _make_test_rgb(w, h, None))
|
|
rc = main(["compare", refs_path, "corner-structural", removed_path])
|
|
check("structural fails when the shape is removed", rc == 1)
|
|
|
|
# bad-path coverage
|
|
rc = main(["compare", refs_path, "no-such-name", base_path])
|
|
check("compare on an unknown reference name fails loudly", rc == 1)
|
|
rc = main(["hash", base_path])
|
|
check("hash on a real ppm succeeds", rc == 0)
|
|
|
|
if failures:
|
|
print(f"\n{len(failures)} check(s) failed:")
|
|
for label in failures:
|
|
print(f" - {label}")
|
|
return 1
|
|
print("\nall checks passed")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main(argv):
|
|
if not argv:
|
|
print(__doc__, file=sys.stderr)
|
|
return 2
|
|
cmd, rest = argv[0], argv[1:]
|
|
if cmd == "selftest":
|
|
return selftest()
|
|
handlers = {"compare": cmd_compare, "capture": cmd_capture, "hash": cmd_hash}
|
|
if cmd not in handlers:
|
|
print(f"unknown command: {cmd!r}", file=sys.stderr)
|
|
return 2
|
|
# A missing file, unreadable ref JSON, or malformed PPM/crop box is a
|
|
# prerequisite failure -- report it plainly and fail loudly (exit 1),
|
|
# never let it fall through as a silent pass or a raw traceback.
|
|
try:
|
|
return handlers[cmd](rest)
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"FATAL: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|