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 # occupancy thresholds for structural(): a downsampled cell counts as
# "occupied" if it is meaningfully brighter than black, or sits on an edge. # "occupied" if it is meaningfully brighter than black, or sits on an edge.
# Both are 0..255 greyscale/edge-magnitude averages over the cell. # Both are 0..255 greyscale/edge-magnitude averages over the cell.
NONBLACK_THRESHOLD = 10 # structural(): a cell is occupied when its grey is this far from the crop's
EDGE_THRESHOLD = 10 # 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): def structural(img):
"""16x16 binary occupancy mask as 32 bytes (256 bits, MSB first, """16x16 binary occupancy mask as 32 bytes (256 bits, MSB first,
row-major). A cell is "occupied" if it is meaningfully non-black or row-major). A cell is "occupied" if it carries ink relative to the crop's
sits on an edge, so the mask is robust to a recolour (still occupied) OWN background -- its grey deviates from the crop's median by more than
but sensitive to a shape disappearing (goes from occupied to empty).""" 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") grey = _to_pil(img).convert("L")
edges = grey.filter(ImageFilter.FIND_EDGES) edges = grey.filter(ImageFilter.FIND_EDGES)
grey_small = grey.resize((16, 16), Image.BOX) grey_small = grey.resize((16, 16), Image.BOX)
edge_small = edges.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() gpx, epx = grey_small.load(), edge_small.load()
bits = bytearray(32) bits = bytearray(32)
idx = 0 idx = 0
for y in range(16): for y in range(16):
for x 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: if occupied:
bits[idx // 8] |= 1 << (7 - (idx % 8)) bits[idx // 8] |= 1 << (7 - (idx % 8))
idx += 1 idx += 1
return bytes(bits) 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): def _structural_diff(a, b):
"""Count of differing bits between two 32-byte occupancy masks.""" """Count of differing bits between two 32-byte occupancy masks."""
return sum(bin(x ^ y).count("1") for x, y in zip(a, b)) return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
+12 -2
View File
@@ -97,6 +97,7 @@ A step that names an unknown command is a FATAL error rather than a skip: a
silently-ignored line in a scenario is a test that proves nothing. silently-ignored line in a scenario is a test that proves nothing.
""" """
import json import json
import math
import os import os
import re import re
import shutil import shutil
@@ -157,9 +158,18 @@ def btn_ev(down):
def to_axis(px, size): def to_axis(px, size):
"""Panel pixels -> the tablet's absolute axis, clamped to the panel.""" """Panel pixels -> the tablet's absolute axis, clamped to the panel.
LVGL's evdev driver maps the axis back with integer truncation
(lv_evdev.c _evdev_calibrate: px = axis * (width - 1) / AXIS_MAX), so the
axis value must be the SMALLEST one that truncates to px, i.e. rounded up.
Truncating here as well composed two floors and landed one pixel short for
many values (130 -> 5924 -> 129), which is how a tap could miss the control
that `hit` at the same pixel had just confirmed (flare-edge #148 triage).
tools/touch-inject writes the pixel itself on a panel, so hardware never
had this seam."""
px = max(0, min(size - 1, int(px))) px = max(0, min(size - 1, int(px)))
return int(px * AXIS_MAX / (size - 1)) return min(AXIS_MAX, math.ceil(px * AXIS_MAX / (size - 1)))
def do_tap(s, f, ax, ay, hold=0.2): def do_tap(s, f, ax, ay, hold=0.2):
+11
View File
@@ -85,6 +85,17 @@ def run_script(text, refs=None):
class PureHelpers(unittest.TestCase): class PureHelpers(unittest.TestCase):
def test_every_pixel_round_trips_through_lvgl_calibration(self):
# lv_evdev.c _evdev_calibrate: px = axis * (width - 1) / AXIS_MAX,
# integer division. A tap requested at px must land at px, for every
# px, or a hit-confirmed target can be missed by one pixel.
for size in (720, 480, 1024):
for px in range(size):
axis = qmp.to_axis(px, size)
self.assertTrue(0 <= axis <= qmp.AXIS_MAX)
back = axis * (size - 1) // qmp.AXIS_MAX
self.assertEqual(back, px, f"size {size}: px {px} -> axis {axis} -> {back}")
def test_resolve_path_and_ops(self): def test_resolve_path_and_ops(self):
doc = {"a": {"b": [5, 6]}, "s": "connected"} doc = {"a": {"b": [5, 6]}, "s": "connected"}
self.assertEqual(qmp.resolve_path(doc, "a.b[1]"), (6, None)) self.assertEqual(qmp.resolve_path(doc, "a.b[1]"), (6, None))