Hama myScan Linux driver tools: scan mode, frame decoding, stitching
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>
This commit is contained in:
+107
@@ -0,0 +1,107 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/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.
|
||||
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
|
||||
|
||||
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=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("--exposure", type=int, help="fixed E6 value instead of auto-exposure")
|
||||
a = ap.parse_args()
|
||||
os.makedirs(a.out, exist_ok=True)
|
||||
|
||||
x = XU(a.video)
|
||||
e6 = a.exposure or 40
|
||||
rows = []
|
||||
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()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
print("restore mouse mode:", x.enable_mouse()[0])
|
||||
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",
|
||||
"b14", "b15", "b16", "flags", "e6", "brightness", "raw"])
|
||||
w.writerows(rows)
|
||||
print(f"{len(rows)} frames saved to {a.out}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Put the myScan into scan mode the way Hal::OVMouseManager::startVideo does,
|
||||
grab a few seconds of frames, decode the embedded sensor records, then restore
|
||||
mouse mode."""
|
||||
import argparse
|
||||
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
|
||||
|
||||
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=4)
|
||||
ap.add_argument("-o", "--out", default="scantest")
|
||||
ap.add_argument("--video", default="/dev/video4")
|
||||
a = ap.parse_args()
|
||||
os.makedirs(a.out, exist_ok=True)
|
||||
|
||||
x = XU(a.video)
|
||||
print("D8 before:", hex(x.bridge_read(0xD8)))
|
||||
try:
|
||||
print("disable mouse:", x.disable_mouse())
|
||||
print("2D=60:", x.mcu_write_verified(0x2D, 0x60), " E7=28:", x.mcu_write_verified(0xE7, 0x28))
|
||||
print("start scan:", x.start_scan())
|
||||
|
||||
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)
|
||||
t0, n = time.time(), 0
|
||||
while time.time() - t0 < a.duration:
|
||||
ok, f = cap.read()
|
||||
if not ok:
|
||||
continue
|
||||
raw = f.tobytes()
|
||||
clean, rec = fix_and_extract(raw)
|
||||
im = cv2.imdecode(np.frombuffer(clean, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||
cv2.imwrite(f"{a.out}/f{n:04d}.png", im)
|
||||
if n % 10 == 0:
|
||||
r = rec.raw.hex() if rec else None
|
||||
print(f"#{n:3d} {len(raw):6d}B bright={im.mean():6.1f} rec={r}", flush=True)
|
||||
n += 1
|
||||
cap.release()
|
||||
print(f"{n} frames -> {a.out}/")
|
||||
finally:
|
||||
print("enable mouse:", x.enable_mouse())
|
||||
print("D8 after:", hex(x.bridge_read(0xD8)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stitch a directory recorded by tools/record.py into a page image."""
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
|
||||
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
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("dir")
|
||||
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("--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))
|
||||
|
||||
st = None
|
||||
for i in rng:
|
||||
im = cv2.imread(os.path.join(a.dir, f"f{frames[i]:05d}.png"), cv2.IMREAD_GRAYSCALE)
|
||||
if st is None:
|
||||
st = Stitcher(im.shape, refine=not a.no_refine, rot_gain=a.rot_gain)
|
||||
st.add(im, deltas[i])
|
||||
out = a.out or os.path.join(a.dir, "page.png")
|
||||
cv2.imwrite(out, st.image())
|
||||
print(f"{st.n} frames -> {out} {st.stats}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user