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