qemu: structural hash relative to the background, taps that land on the pixel

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
This commit is contained in:
Noah
2026-09-08 13:53:59 -06:00
co-authored by Claude Fable 5.1
parent f809c6e5a3
commit bd5c5af9db
3 changed files with 56 additions and 8 deletions
+33 -6
View File
@@ -37,8 +37,12 @@ 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.
NONBLACK_THRESHOLD = 10
EDGE_THRESHOLD = 10
# 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
# ---------------------------------------------------------------------------
@@ -181,25 +185,48 @@ def hamming(a, b):
def structural(img):
"""16x16 binary occupancy mask as 32 bytes (256 bits, MSB first,
row-major). A cell is "occupied" if it is meaningfully non-black 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)."""
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 = gpx[x, y] > NONBLACK_THRESHOLD or epx[x, y] > EDGE_THRESHOLD
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))