Files
myscan/myscan/calib.py
T
erikandClaude Opus 5.5 026aa6c894 Global page assembly with loop closures and lens undistortion
The frame-by-frame stitcher accumulated drift, so later passes landed rotated
and offset, doubling text. myscan/pagegraph.py instead solves all frame poses
together: ECC odometry between consecutive frames (bridging single corrupt
frames), SIFT+RANSAC loop closures between overlapping passes, and a robust
Gauss-Newton pose graph. Loop matches must agree over the whole overlap, which
rejects matches on repeated text. Off-page frames and frames that disagree
with the mosaic are left out of the render.

calib.Undistort applies the EEPROM lens model (OpenCV k1..k3, p1, p2 plus
the tilt homography, as decoded in EEPROM.md); up to 8.8 px at the corners.

scan.py registers frames on a worker thread so capture no longer drops
frames, then assembles the page after the stop press (~11 s for a full
page). stitch.py uses the new assembly by default; --live keeps the old one.

EEPROM.md documents the full calibration blob layout, checksum and signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:06:45 +00:00

160 lines
6.6 KiB
Python

"""Per-device calibration stored in the mouse's EEPROM (written at the factory).
Layout (from the Mac app, see RE_colour_stitch.md 1.4): 0x3000 u16 LE blob length N,
0x3002 4-byte checksum, 0x3006 N-byte boost::archive binary blob (i386 sizes) holding
Roi, Sensor, Undistortion, Color and Info params. Fields are located by structure, not
fully deserialised; offsets below were checked against one unit only.
Colour calibration is two sets of point samples, black and white, each
`u16 x, u16 y, u32 3, u8 b, g, r` in raw-frame coordinates, which the app fits with
NURBS surfaces and uses for a per-pixel linear correction (`slope*in + intercept`,
white -> colorDestBrightImage = 250).
"""
import os
import struct
import cv2
import numpy as np
EEPROM_BASE = 0x3000
DEST_WHITE = 250.0
CACHE = os.path.join(os.path.dirname(__file__), "..", "calib", "eeprom_3000.bin")
def read_eeprom(x, cache=CACHE):
"""Header + blob, cached on disk after the first (~15 s) read."""
if cache and os.path.exists(cache):
return open(cache, "rb").read()
head = x.eeprom_read(EEPROM_BASE, 6)
n = struct.unpack("<H", head[:2])[0]
if not 0 < n <= 20000:
raise ValueError(f"no calibration blob (length {n})")
data = head + x.eeprom_read(EEPROM_BASE + 6, n)
if cache:
os.makedirs(os.path.dirname(cache), exist_ok=True)
open(cache, "wb").write(data)
return data
def _sample_runs(blob, min_len=50):
"""Find runs of 11-byte colour samples (the u32 3 marks each one)."""
runs, i = [], 4
while i < len(blob) - 11:
j = i
while blob[j:j + 4] == b"\x03\x00\x00\x00":
j += 11
if (j - i) // 11 >= min_len:
runs.append((i - 4, (j - i) // 11))
i = j
else:
i += 1
return runs
def parse(data):
blob = data[6:]
if blob[4:26] != b"serialization::archive":
raise ValueError("not a boost archive")
d = lambda o: struct.unpack_from("<d", blob, o)[0] # noqa: E731
runs = _sample_runs(blob)
if len(runs) != 2:
raise ValueError(f"expected black+white sample runs, found {len(runs)}")
samples = [np.array([struct.unpack_from("<2HI3B", blob, s + 11 * k) for k in range(n)],
np.float32)[:, [0, 1, 3, 4, 5]] for s, n in runs]
end = runs[1][0] + 11 * runs[1][1]
return {
"roi": struct.unpack_from("<4i", blob, 0x29), # x0, y0, x1, y1
"meters_per_unit": (d(0x3B), d(0x43)), # left, right laser
"laser_left_m": (d(0x5B), d(0x63)),
"laser_right_m": (d(0x73), d(0x7B)),
"focal_px": d(0x85), "principal_px": (d(0x8D), d(0x95)),
"dist_coeffs": struct.unpack_from("<5d", blob, 0xA5), # OpenCV k1, k2, p1, p2, k3
"tilt_h": struct.unpack_from("<8d", blob, 0xD1) + (1.0,), # 3x3 row-major, pixel space
"pixel_size_m": d(0x111),
"black": samples[0], "white": samples[1], # columns x, y, b, g, r
"gains_rgb": struct.unpack_from("<3i", blob, end + 4), # sensor regs 1, 2, 3
}
def _bspline_basis(t, n_ctrl, length):
"""Uniform cubic B-spline basis (len(t) x n_ctrl) on [0, length)."""
seg = n_ctrl - 3
u = np.clip(np.asarray(t, np.float64) / length * seg, 0, seg - 1e-9)
k = u.astype(int)
f = u - k
w = np.stack([(1 - f) ** 3, 3 * f ** 3 - 6 * f ** 2 + 4, -3 * f ** 3 + 3 * f ** 2 + 3 * f + 1, f ** 3], 1) / 6
B = np.zeros((len(u), n_ctrl))
for j in range(4):
B[np.arange(len(u)), k + j] = w[:, j]
return B
def fit_surface(s, shape, ctrl=(6, 10), ridge=1.0):
"""Smooth surface (H x W x 3) through scattered samples (x, y, b, g, r)."""
h, w = shape
ny, nx = ctrl
By, Bx = _bspline_basis(s[:, 1], ny, h), _bspline_basis(s[:, 0], nx, w)
A = (By[:, :, None] * Bx[:, None, :]).reshape(len(s), -1)
# Ridge towards the mean keeps sparsely sampled corners from running off.
mean = s[:, 2:].mean(0)
C = np.linalg.solve(A.T @ A + ridge * np.eye(A.shape[1]), A.T @ (s[:, 2:] - mean)) + mean
Gy, Gx = _bspline_basis(np.arange(h), ny, h), _bspline_basis(np.arange(w), nx, w)
return np.einsum("yi,ijc,xj->yxc", Gy, C.reshape(ny, nx, 3), Gx).astype(np.float32)
class FlatField:
"""Per-pixel colour correction from the EEPROM black/white surfaces.
Calling the object divides a frame by the white surface normalised to mean 1 per
channel: that removes the LED/lens falloff whatever the current exposure and sensor
gains, leaving overall colour balance to the stitcher's white patch. `apply` is the
app's full linear correction, valid only at the factory exposure and gains."""
def __init__(self, cal, shape=(296, 640)):
self.black = fit_surface(cal["black"], shape)
self.white = fit_surface(cal["white"], shape)
self.shape = self.white / self.white.reshape(-1, 3).mean(0)
self.inv = 1 / self.shape
self.inv_gray = 1 / self.shape.mean(2)
def __call__(self, frame):
return frame.astype(np.float32) * (self.inv if frame.ndim == 3 else self.inv_gray)
def apply(self, frame, level=1.0):
f = frame.astype(np.float32)
b, w = self.black * level, self.white * level
if f.ndim == 2:
b, w = b.mean(2), w.mean(2)
return (f - b) * (DEST_WHITE / np.maximum(w - b, 1))
class Undistort:
"""Lens + tilt correction, as the app's generateMapFromParams (EEPROM.md): for each
output pixel p, the source pixel is K * distort(inv(K) * inv(H) * p)."""
def __init__(self, cal, shape=(296, 640)):
h, w = shape
f, (cx, cy) = cal["focal_px"], cal["principal_px"]
K = np.array([[f, 0, cx], [0, f, cy], [0, 0, 1.0]])
M = np.linalg.inv(K) @ np.linalg.inv(np.reshape(cal["tilt_h"], (3, 3)))
k1, k2, p1, p2, k3 = cal["dist_coeffs"]
ys, xs = np.mgrid[0:h, 0:w].astype(np.float64)
X, Y, W = (M @ np.stack([xs.ravel(), ys.ravel(), np.ones(xs.size)])).reshape(3, h, w)
x, y = X / W, Y / W
r2 = x * x + y * y
radial = 1 + k1 * r2 + k2 * r2 ** 2 + k3 * r2 ** 3
xd = x * radial + 2 * p1 * x * y + p2 * (r2 + 2 * x * x)
yd = y * radial + p1 * (r2 + 2 * y * y) + 2 * p2 * x * y
self.map = cv2.convertMaps((f * xd + cx).astype(np.float32), (f * yd + cy).astype(np.float32),
cv2.CV_16SC2)
def __call__(self, frame):
return cv2.remap(frame, *self.map, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
def load(x=None, cache=CACHE):
if x is None and not (cache and os.path.exists(cache)):
from .xu import XU
x = XU()
return parse(read_eeprom(x, cache))