From bd5c5af9db74bd0ce321cef64399315ec8906eb7 Mon Sep 17 00:00:00 2001 From: Noah Date: Tue, 8 Sep 2026 13:53:59 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj --- qemu/tests/imgtools.py | 39 ++++++++++++++++++++++++++++++------ qemu/tests/qmp.py | 14 +++++++++++-- qemu/tests/test_qmp_drive.py | 11 ++++++++++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/qemu/tests/imgtools.py b/qemu/tests/imgtools.py index ff61f56..4c7e043 100644 --- a/qemu/tests/imgtools.py +++ b/qemu/tests/imgtools.py @@ -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)) diff --git a/qemu/tests/qmp.py b/qemu/tests/qmp.py index f0e49c8..627c0fb 100755 --- a/qemu/tests/qmp.py +++ b/qemu/tests/qmp.py @@ -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. """ import json +import math import os import re import shutil @@ -157,9 +158,18 @@ def btn_ev(down): 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))) - 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): diff --git a/qemu/tests/test_qmp_drive.py b/qemu/tests/test_qmp_drive.py index acbd3ae..122c966 100755 --- a/qemu/tests/test_qmp_drive.py +++ b/qemu/tests/test_qmp_drive.py @@ -85,6 +85,17 @@ def run_script(text, refs=None): 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): doc = {"a": {"b": [5, 6]}, "s": "connected"} self.assertEqual(qmp.resolve_path(doc, "a.b[1]"), (6, None))