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