Live button-driven scanning with EEPROM calibration

- ScanSession: scan-mode context manager with bright-pixel auto-exposure and
  sensor colour gains; tools/scan.py scans on the scan button with live stitching
- Read-only EEPROM access and calib.py parser (laser geometry, intrinsics,
  colour gains, black/white flat-field samples); flat-field correction in Stitcher
- OV7740 sensor access via the MCU proxy; colour gains from EEPROM fix green cast
- Stitcher: colour support, high-passed registration (robust on blank paper)
- RE notes on the Mac app's colour, exposure, stitching and button handling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-02 11:13:59 +00:00
co-authored by Claude Opus 5.5
parent e1c1012c1c
commit e70d56d957
11 changed files with 919 additions and 82 deletions
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Scan mode for a few seconds while logging every place the scan button might
show up (bridge reg 0xBC, HID report bit 7, record flags) plus colour statistics.
Saves a colour frame every second to <out>/. Mouse mode is restored on exit."""
import argparse
import os
import select
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.stderr = os.fdopen(os.dup(2), "w")
os.dup2(os.open(os.devnull, os.O_WRONLY), 2) # silence libjpeg warnings
import cv2 # noqa: E402
import numpy as np # noqa: E402
from myscan.frame import fix_and_extract # noqa: E402
from myscan.xu import XU # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-d", "--duration", type=float, default=12)
ap.add_argument("-o", "--out", default="btnprobe")
ap.add_argument("--video", default="/dev/video4")
ap.add_argument("--hid", default="/dev/hidraw8")
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
x = XU(a.video)
hid = os.open(a.hid, os.O_RDONLY | os.O_NONBLOCK)
log = open(os.path.join(a.out, "log.csv"), "w")
log.write("t,src,value\n")
try:
if not (x.disable_mouse()[0] and x.mcu_write_verified(0x2D, 0x60)
and x.mcu_write_verified(0xE7, 0x28) and x.mcu_write_verified(0xE6, 27)
and x.start_scan()[0]):
sys.exit("failed to enter scan mode")
cap = cv2.VideoCapture(a.video, cv2.CAP_V4L2)
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
cap.set(cv2.CAP_PROP_CONVERT_RGB, 0)
print(f"scan mode for {a.duration:.0f}s: press and release the scan button a few times", flush=True)
t0, n, last, state = time.time(), 0, -1.0, {}
while time.time() - t0 < a.duration:
ok, f = cap.read()
t = time.time() - t0
while select.select([hid], [], [], 0)[0]:
r = os.read(hid, 64)
state["hid"] = r[0] >> 7
log.write(f"{t:.3f},hid,{r.hex()}\n")
if not ok:
continue
clean, rec = fix_and_extract(f.tobytes())
im = cv2.imdecode(np.frombuffer(clean, np.uint8), cv2.IMREAD_COLOR)
if im is None or rec is None:
continue
bc = x.bridge_read(0xBC)
log.write(f"{t:.3f},bc,{bc}\n{t:.3f},flags,{rec.flags}\n{t:.3f},b16,{rec.b16}\n")
changes = [k for k, v in (("bc", bc), ("flags", rec.flags), ("b16", rec.b16)) if state.get(k) != v]
for k, v in (("bc", bc), ("flags", rec.flags), ("b16", rec.b16)):
state[k] = v
if changes:
print(f"t={t:5.2f} " + " ".join(f"{k}={state.get(k)}" for k in ("bc", "flags", "b16", "hid")), flush=True)
if t - last >= 1:
m = im.reshape(-1, 3).mean(0)
sat = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)[..., 1].mean()
print(f"t={t:5.2f} colour mean BGR={m.round(1)} saturation={sat:.1f}", flush=True)
cv2.imwrite(f"{a.out}/c{n:03d}.png", im)
last = t
n += 1
cap.release()
except KeyboardInterrupt:
pass
finally:
print("restore mouse mode:", x.enable_mouse()[0])
log.close()
if __name__ == "__main__":
main()
+24 -46
View File
@@ -1,22 +1,19 @@
#!/usr/bin/env python3
"""Record a scan session: scan mode + auto-exposure, saving every frame (fixed
640x296 grayscale PNG) and its embedded sensor record to records.csv.
640x296 colour PNG) and its embedded sensor record to records.csv.
Mouse mode is restored on exit (also on Ctrl-C)."""
import argparse
import csv
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.stderr = os.fdopen(os.dup(2), "w")
os.dup2(os.open(os.devnull, os.O_WRONLY), 2) # silence libjpeg warnings
from myscan import calib # noqa: E402
from myscan.session import ScanSession, quiet_libjpeg # noqa: E402
quiet_libjpeg()
import cv2 # noqa: E402
import numpy as np # noqa: E402
from myscan.frame import fix_and_extract # noqa: E402
from myscan.xu import XU # noqa: E402
def main():
@@ -24,53 +21,34 @@ def main():
ap.add_argument("-d", "--duration", type=float, default=20)
ap.add_argument("-o", "--out", default="scan")
ap.add_argument("--video", default="/dev/video4")
ap.add_argument("--target", type=float, default=150, help="auto-exposure target mean brightness")
ap.add_argument("--target", type=float, default=200, help="auto-exposure target for paper (95th pct of brightest channel)")
ap.add_argument("--exposure", type=int, help="fixed E6 value instead of auto-exposure")
ap.add_argument("--gray", action="store_true", help="save grayscale frames")
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
x = XU(a.video)
e6 = a.exposure or 40
rows = []
s = ScanSession(a.video, a.target, a.exposure, gains=calib.load()["gains_rgb"])
try:
if not (x.disable_mouse()[0] and x.mcu_write_verified(0x2D, 0x60)
and x.mcu_write_verified(0xE7, 0x28) and x.mcu_write_verified(0xE6, e6)
and x.start_scan()[0]):
sys.exit("failed to enter scan mode")
cap = cv2.VideoCapture(a.video, cv2.CAP_V4L2)
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
cap.set(cv2.CAP_PROP_CONVERT_RGB, 0)
print(f"scanning for {a.duration:.0f}s -> {a.out}/ (Ctrl-C to stop early)", flush=True)
t0, n, last = time.time(), 0, 0.0
while time.time() - t0 < a.duration:
ok, f = cap.read()
if not ok:
continue
t = time.time() - t0
clean, rec = fix_and_extract(f.tobytes())
im = cv2.imdecode(np.frombuffer(clean, np.uint8), cv2.IMREAD_GRAYSCALE)
if im is None or rec is None:
continue
cv2.imwrite(f"{a.out}/f{n:05d}.png", im)
mean = float(im.mean())
rows.append([round(t, 4), n, rec.counter, *rec.v, rec.b14, rec.b15, rec.b16, rec.flags, e6,
round(mean, 1), rec.raw.hex()])
# Proportional auto-exposure every 3 frames (brightness is ~linear in E6).
if a.exposure is None and n % 3 == 0 and mean > 1:
new = int(min(255, max(2, round(e6 * (a.target / mean) ** 0.7))))
if new != e6 and x.mcu_write_verified(0xE6, new):
e6 = new
if t - last >= 1:
print(f"t={t:5.1f}s frames={n:4d} E6={e6:3d} bright={mean:5.1f} "
f"l1=({rec.v[0]:#06x},{rec.v[1]:#06x}) l2=({rec.v[3]:#06x},{rec.v[4]:#06x}) "
f"sq={rec.b14},{rec.b15} flags={rec.flags:05b}", flush=True)
last = t
n += 1
cap.release()
with s:
print(f"scanning for {a.duration:.0f}s -> {a.out}/ (Ctrl-C to stop early)", flush=True)
last = 0.0
for t, n, im, rec in s.frames(color=not a.gray):
if t >= a.duration:
break
cv2.imwrite(f"{a.out}/f{n:05d}.png", im)
mean = float(im.mean())
rows.append([round(t, 4), n, rec.counter, *rec.v, rec.b14, rec.b15, rec.b16, rec.flags, s.e6,
round(mean, 1), rec.raw.hex()])
if t - last >= 1:
print(f"t={t:5.1f}s frames={n:4d} E6={s.e6:3d} bright={mean:5.1f} "
f"l1=({rec.v[0]:#06x},{rec.v[1]:#06x}) l2=({rec.v[3]:#06x},{rec.v[4]:#06x}) "
f"sq={rec.b14},{rec.b15} flags={rec.flags:05b}", flush=True)
last = t
except KeyboardInterrupt:
pass
finally:
print("restore mouse mode:", x.enable_mouse()[0])
print("restore mouse mode:", getattr(s, "restored", None))
with open(f"{a.out}/records.csv", "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["t", "frame", "counter", "v0", "v1", "v2", "v3", "v4", "v5",
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Scan with the scan button, like the original app: press it to start, glide the
mouse over the page, press it again to stop. The page is stitched live and saved
as <outdir>/scan-YYYYmmdd-HHMMSS.png; the mouse goes back to mouse mode between
scans (and on Ctrl-C)."""
import argparse
import csv
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from myscan.session import ScanSession, quiet_libjpeg # noqa: E402
quiet_libjpeg()
import cv2 # noqa: E402
from myscan import calib # noqa: E402
from myscan.stitch import Stitcher # noqa: E402
from myscan.xu import XU # noqa: E402
def wait_press(x, poll=0.05):
"""Block until the scan button goes down then up again."""
prev = 1
while True:
b = x.scan_button()
if b and not prev:
break
prev = b
time.sleep(poll)
while x.scan_button():
time.sleep(poll)
def scan_page(x, a, frames_dir=None, flat=None, gains=None):
st, rows = None, []
with ScanSession(a.video, a.target, a.exposure, xu=x, gains=gains) as s:
down, last = 0, 0.0
for t, n, im, rec in s.frames(color=not a.gray):
if st is None:
st = Stitcher(im.shape, refine=not a.no_refine, flat=flat)
st.add(im, rec.v)
if frames_dir:
cv2.imwrite(os.path.join(frames_dir, f"f{n:05d}.png"), im)
rows.append([round(t, 4), n, rec.counter, *rec.v, rec.b14, rec.b15, rec.b16, rec.flags, s.e6,
round(float(im.mean()), 1), rec.raw.hex()])
# Stop on a press: two "down" reads in a row (debounce), ignoring the first
# 0.7 s so a bouncing start press can't end the scan.
if n % 2 == 0:
down = down + 1 if x.scan_button() else 0
if down >= 2 and t > 0.7:
break
if t >= a.max_time:
print("max scan time reached", flush=True)
break
if t - last >= 1:
print(f"\r {t:5.1f}s {st.n} frames canvas {st.size[0]}x{st.size[1]} E6={s.e6}",
end="", flush=True)
last = t
print()
if frames_dir: # same layout as tools/record.py, so tools/stitch.py can replay it
with open(os.path.join(frames_dir, "records.csv"), "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["t", "frame", "counter", "v0", "v1", "v2", "v3", "v4", "v5",
"b14", "b15", "b16", "flags", "e6", "brightness", "raw"])
w.writerows(rows)
return st
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-o", "--outdir", default="scans")
ap.add_argument("--once", action="store_true", help="exit after one page")
ap.add_argument("--now", action="store_true", help="start the first scan immediately")
ap.add_argument("--video", default="/dev/video4")
ap.add_argument("--target", type=float, default=200, help="auto-exposure target for paper (95th pct of brightest channel)")
ap.add_argument("--exposure", type=int, help="fixed E6 value instead of auto-exposure")
ap.add_argument("--gray", action="store_true")
ap.add_argument("--no-wb", action="store_true", help="skip white-patch balancing")
ap.add_argument("--no-flat", action="store_true", help="skip the EEPROM flat-field correction")
ap.add_argument("--no-refine", action="store_true", help="lasers only, no image registration")
ap.add_argument("--save-frames", action="store_true", help="also keep raw frames + records.csv per scan")
ap.add_argument("--max-time", type=float, default=180, help="stop a scan after this many seconds")
a = ap.parse_args()
os.makedirs(a.outdir, exist_ok=True)
x = XU(a.video)
print("reading calibration from the mouse's EEPROM (cached after the first time)", flush=True)
cal = calib.load(x)
flat = None if a.no_flat else calib.FlatField(cal)
gains = cal["gains_rgb"]
first = True
try:
while True:
if not (first and a.now):
print("press the scan button to start (Ctrl-C to quit)", flush=True)
wait_press(x)
first = False
print("scanning - press the scan button again to stop", flush=True)
name = time.strftime("scan-%Y%m%d-%H%M%S")
frames_dir = os.path.join(a.outdir, name) if a.save_frames else None
if frames_dir:
os.makedirs(frames_dir)
st = scan_page(x, a, frames_dir, flat, gains)
if st is None or st.n < 2:
print("no frames captured")
else:
path = os.path.join(a.outdir, name + ".png")
cv2.imwrite(path, st.image(white_balance=not a.no_wb))
print(f"saved {path} ({st.n} frames, {st.stats})", flush=True)
if a.once:
break
except KeyboardInterrupt:
print("\nbye")
if __name__ == "__main__":
main()
+9 -6
View File
@@ -10,7 +10,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import cv2 # noqa: E402
import numpy as np # noqa: E402
from myscan.stitch import ROT_GAIN, Stitcher, laser_deltas # noqa: E402
from myscan import calib # noqa: E402
from myscan.stitch import ROT_GAIN, Stitcher # noqa: E402
def main():
@@ -19,24 +20,26 @@ def main():
ap.add_argument("-o", "--out", help="output image (default <dir>/page.png)")
ap.add_argument("--no-refine", action="store_true", help="lasers only, no image registration")
ap.add_argument("--rot-gain", type=float, default=ROT_GAIN)
ap.add_argument("--no-flat", action="store_true", help="skip the EEPROM flat-field correction")
ap.add_argument("--no-wb", action="store_true", help="skip white-patch balancing")
ap.add_argument("--start", type=int, default=0)
ap.add_argument("--end", type=int)
a = ap.parse_args()
rows = list(csv.DictReader(open(os.path.join(a.dir, "records.csv"))))
v = np.array([[int(r[f"v{k}"]) for k in range(6)] for r in rows])
deltas = laser_deltas(v)
frames = [int(r["frame"]) for r in rows]
rng = range(a.start, a.end if a.end is not None else len(rows))
flat = None if a.no_flat else calib.FlatField(calib.load())
st = None
for i in rng:
im = cv2.imread(os.path.join(a.dir, f"f{frames[i]:05d}.png"), cv2.IMREAD_GRAYSCALE)
im = cv2.imread(os.path.join(a.dir, f"f{frames[i]:05d}.png"), cv2.IMREAD_UNCHANGED)
if st is None:
st = Stitcher(im.shape, refine=not a.no_refine, rot_gain=a.rot_gain)
st.add(im, deltas[i])
st = Stitcher(im.shape, refine=not a.no_refine, rot_gain=a.rot_gain, flat=flat)
st.add(im, v[i])
out = a.out or os.path.join(a.dir, "page.png")
cv2.imwrite(out, st.image())
cv2.imwrite(out, st.image(white_balance=not a.no_wb))
print(f"{st.n} frames -> {out} {st.stats}")