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
+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",