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