UVC extension-unit control (mouse/scan mode, LEDs, exposure), JPEG header fix and embedded laser record decoding, session recorder, and a stitcher that combines laser dead reckoning with phase correlation and ECC refinement. FINDINGS.md holds the reverse-engineered protocol and laser calibration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Record camera frames and HID mouse reports from the Hama myScan side by side.
|
|
|
|
Saves every raw MJPEG frame plus a timeline (frames.csv, hid.csv) into an output
|
|
directory and prints a once-per-second summary, so we can see how the camera
|
|
reacts to buttons, movement and surfaces.
|
|
"""
|
|
import argparse
|
|
import csv
|
|
import os
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
# libjpeg prints "Corrupt JPEG data" warnings straight to fd 2 for every
|
|
# truncated frame; keep Python's stderr but silence the C-level noise.
|
|
sys.stderr = os.fdopen(os.dup(2), "w")
|
|
os.dup2(os.open(os.devnull, os.O_WRONLY), 2)
|
|
|
|
import cv2 # noqa: E402
|
|
import numpy as np # noqa: E402
|
|
|
|
|
|
def hid_reader(path, out, stop, t0):
|
|
# Report layout (from the report descriptor): 1 byte buttons (5 bits),
|
|
# int16 X, int16 Y, int8 wheel, int8 AC pan.
|
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
|
while not stop.is_set():
|
|
try:
|
|
r = os.read(fd, 64)
|
|
except BlockingIOError:
|
|
time.sleep(0.002)
|
|
continue
|
|
if len(r) >= 7:
|
|
b, x, y, w, p = struct.unpack("<BhhbB", r[:7])
|
|
out.append((time.time() - t0, b, x, y, w, p, r.hex()))
|
|
os.close(fd)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("-d", "--duration", type=float, default=30)
|
|
ap.add_argument("-o", "--out", default="capture")
|
|
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)
|
|
cap = cv2.VideoCapture(a.video, cv2.CAP_V4L2)
|
|
if not cap.isOpened():
|
|
sys.exit(f"cannot open {a.video}")
|
|
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
|
|
cap.set(cv2.CAP_PROP_CONVERT_RGB, 0)
|
|
|
|
hid, stop, t0 = [], threading.Event(), time.time()
|
|
th = threading.Thread(target=hid_reader, args=(a.hid, hid, stop, t0), daemon=True)
|
|
th.start()
|
|
|
|
frames, n, last_print, hid_seen = [], 0, 0.0, 0
|
|
print(f"recording {a.duration:.0f}s into {a.out}/ ... move the mouse, press buttons", flush=True)
|
|
while time.time() - t0 < a.duration:
|
|
ok, f = cap.read()
|
|
if not ok:
|
|
continue
|
|
t = time.time() - t0
|
|
raw = f.tobytes()
|
|
with open(f"{a.out}/f{n:05d}.jpg", "wb") as fh:
|
|
fh.write(raw)
|
|
im = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_GRAYSCALE)
|
|
if im is None:
|
|
mean, rows = -1, 0
|
|
else:
|
|
# Rows past the end of the (truncated) entropy data decode as flat 128.
|
|
filled = np.all(im == 128, axis=1)
|
|
rows = int(np.argmax(filled)) if filled.any() else im.shape[0]
|
|
mean = float(im[:rows].mean()) if rows else 0.0
|
|
frames.append((t, n, len(raw), rows, round(mean, 1)))
|
|
n += 1
|
|
|
|
if t - last_print >= 1.0:
|
|
new = hid[hid_seen:]
|
|
hid_seen = len(hid)
|
|
btn = sorted({h[1] for h in new})
|
|
dx, dy = sum(h[2] for h in new), sum(h[3] for h in new)
|
|
recent = [fr for fr in frames if fr[0] > t - 1.0]
|
|
print(f"t={t:5.1f}s fps={len(recent):2d} size={np.mean([r[2] for r in recent]):7.0f}B "
|
|
f"rows={np.mean([r[3] for r in recent]):5.0f} bright={np.mean([r[4] for r in recent]):6.1f} "
|
|
f"hid={len(new):3d} buttons={btn} dx={dx:+d} dy={dy:+d}", flush=True)
|
|
last_print = t
|
|
|
|
stop.set()
|
|
th.join()
|
|
cap.release()
|
|
with open(f"{a.out}/frames.csv", "w", newline="") as fh:
|
|
w = csv.writer(fh)
|
|
w.writerow(["t", "frame", "bytes", "decoded_rows", "brightness"])
|
|
w.writerows(frames)
|
|
with open(f"{a.out}/hid.csv", "w", newline="") as fh:
|
|
w = csv.writer(fh)
|
|
w.writerow(["t", "buttons", "dx", "dy", "wheel", "pan", "raw"])
|
|
w.writerows(hid)
|
|
print(f"done: {n} frames, {len(hid)} HID reports -> {a.out}/")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|