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:
+33
-6
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user