Files
myscan/myscan/stitch.py
T
erikandClaude Opus 5.5 e70d56d957 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>
2026-10-02 11:13:59 +00:00

200 lines
9.1 KiB
Python

"""Assemble scan frames into a page image.
Poses come from dead reckoning on the two laser sensors embedded in each
frame's record (see frame.py), then each frame is refined against the canvas
built so far with ECC (Euclidean model) before it is blended in.
Laser calibration (from scan_text, 2026-10-02; fit against image registration):
v0/v1 = laser 1 cumulative (a, b) counts, v3/v4 = laser 2; 16-bit wrapping.
content shift between consecutive frames, in frame pixels:
dx = -0.022*a - 0.355*b, dy = 0.324*a + 0.002*b (a, b = mean of both lasers)
image rotation (rad) = ROT_GAIN * (db1 - db2)
"""
import numpy as np
import cv2
SHIFT = np.array([[-0.0222, -0.3551], # dx from (a, b)
[0.3239, 0.0020]]) # dy from (a, b)
ROT_GAIN = -4.4e-4
MIN_PHASE_RESP = 0.2 # phase-correlation peak needed to override the lasers
def _s16(v):
v = np.asarray(v, np.int64) & 0xFFFF
return np.where(v >= 0x8000, v - 0x10000, v)
def laser_deltas(v):
"""v: (N, 6) record values -> (N, 4) per-frame deltas (a1, b1, a2, b2); first row zero."""
p = _s16(np.asarray(v)[:, [0, 1, 3, 4]])
d = np.diff(p, axis=0, prepend=p[:1])
return (d + 0x8000) % 0x10000 - 0x8000
def _aff(theta, tx, ty):
c, s = np.cos(theta), np.sin(theta)
return np.array([[c, -s, tx], [s, c, ty], [0, 0, 1.0]])
def laser_shift(d):
"""Content shift (dx, dy) in frame pixels predicted from one frame's laser deltas."""
return SHIFT @ ((d[0] + d[2]) / 2, (d[1] + d[3]) / 2)
def relative_motion(d, center, rot_gain=ROT_GAIN, shift=None):
"""3x3 transform mapping current-frame pixels to previous-frame pixels.
shift overrides the laser-predicted content shift."""
dx, dy = laser_shift(d) if shift is None else shift
th = rot_gain * (d[1] - d[3])
cx, cy = center
# content moved by (dx, dy) and rotated by th about the center: p_prev = R(-th)(p - c - d) + c
return _aff(0, cx, cy) @ _aff(-th, 0, 0) @ _aff(0, -cx - dx, -cy - dy)
def _highpass(img, mask=None, sigma=12):
"""Remove illumination (vignetting, LED falloff) so registration sees only page texture.
With a mask, the local mean is taken over masked pixels only."""
if mask is None:
return img - cv2.GaussianBlur(img, (0, 0), sigma)
m = mask.astype(np.float32)
mean = cv2.GaussianBlur(img * m, (0, 0), sigma) / np.maximum(cv2.GaussianBlur(m, (0, 0), sigma), 1e-3)
return (img - mean) * m
def _feather(h, w):
y = np.minimum(np.arange(h), np.arange(h)[::-1]) + 1
x = np.minimum(np.arange(w), np.arange(w)[::-1]) + 1
return np.minimum.outer(y, x).astype(np.float32).clip(max=40) / 40
class Stitcher:
"""Feed frames (grayscale or BGR) with their raw record values; registration runs on
grayscale, blending keeps all channels. Dropped frames are fine: the laser
positions are cumulative, so deltas are taken against the last frame added."""
def __init__(self, frame_shape, margin=600, refine=True, rot_gain=ROT_GAIN, flat=None):
self.h, self.w = frame_shape[:2]
self.ch = frame_shape[2] if len(frame_shape) > 2 else 1
self.refine, self.rot_gain, self.flat = refine, rot_gain, flat
self.size = (self.w + 2 * margin, self.h + 2 * margin) # grows as needed
self.acc = np.zeros(self.size[::-1] + ((self.ch,) if self.ch > 1 else ()), np.float32)
self.wsum = np.zeros(self.size[::-1], np.float32)
self.pose = _aff(0, margin, margin) # frame -> canvas
self.feather = _feather(self.h, self.w)
self.window = cv2.createHanningWindow((self.w, self.h), cv2.CV_32F)
self.prev = None # previous frame, high-passed
self.prev_v = None # previous raw record values
self.n = 0
self.stats = {"refined": 0, "rejected": 0, "skipped": 0, "image_shift": 0}
def _corners(self, M):
c = np.array([[0, 0, 1], [self.w, 0, 1], [0, self.h, 1], [self.w, self.h, 1.0]]).T
return (M @ c)[:2]
def _grow(self, M):
q = self._corners(M)
pad = 400
left = int(max(0, pad - q[0].min())) if q[0].min() < 50 else 0
top = int(max(0, pad - q[1].min())) if q[1].min() < 50 else 0
right = int(max(0, q[0].max() + pad - self.size[0])) if q[0].max() > self.size[0] - 50 else 0
bottom = int(max(0, q[1].max() + pad - self.size[1])) if q[1].max() > self.size[1] - 50 else 0
if left or top or right or bottom:
self.acc = cv2.copyMakeBorder(self.acc, top, bottom, left, right, cv2.BORDER_CONSTANT, 0)
self.wsum = cv2.copyMakeBorder(self.wsum, top, bottom, left, right, cv2.BORDER_CONSTANT, 0)
self.size = self.wsum.shape[::-1]
shift = _aff(0, left, top)
self.pose = shift @ self.pose
return shift @ M
return M
def _roi(self, M):
"""Canvas rectangle (x0, y0, x1, y1) covered by a frame at pose M."""
q = self._corners(M)
x0, y0 = np.floor(q.min(1)).astype(int) - 1
x1, y1 = np.ceil(q.max(1)).astype(int) + 2
return max(x0, 0), max(y0, 0), min(x1, self.size[0]), min(y1, self.size[1])
def _gray_canvas(self, x0, y0, x1, y1):
acc = self.acc[y0:y1, x0:x1]
return acc.mean(axis=2) if self.ch > 1 else acc
def _refine(self, gray, M):
# Canvas content seen through the predicted pose, in frame coordinates.
x0, y0, x1, y1 = self._roi(M)
Mi = np.linalg.inv(_aff(0, -x0, -y0) @ M)[:2]
acc = cv2.warpAffine(self._gray_canvas(x0, y0, x1, y1), Mi, (self.w, self.h), flags=cv2.INTER_LINEAR)
ws = cv2.warpAffine(self.wsum[y0:y1, x0:x1], Mi, (self.w, self.h), flags=cv2.INTER_LINEAR)
mask = ws > 0.3
if mask.mean() < 0.3:
self.stats["skipped"] += 1
return M
patch = _highpass(np.where(mask, acc / np.maximum(ws, 1e-6), 0).astype(np.float32), mask)
W = np.eye(2, 3, dtype=np.float32)
try:
_, W = cv2.findTransformECC(patch, gray, W, cv2.MOTION_EUCLIDEAN,
(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 60, 1e-4),
mask.astype(np.uint8), 5)
except cv2.error:
self.stats["rejected"] += 1
return M
# patch(x) ~ frame(W x), patch pixel x sits at canvas M x, so frame pixel y sits at M W^-1 y.
W3 = np.vstack([W, [0, 0, 1]]).astype(np.float64)
if abs(np.arctan2(W3[1, 0], W3[0, 0])) > np.radians(3) or np.hypot(W3[0, 2], W3[1, 2]) > 25:
self.stats["rejected"] += 1
return M
self.stats["refined"] += 1
return M @ np.linalg.inv(W3)
def add(self, frame, v):
"""frame: HxW or HxWx3 uint8; v: the record's six raw values (cumulative laser counts)."""
frame = frame.astype(np.float32) if self.flat is None else self.flat(frame)
gray = frame.mean(axis=2) if frame.ndim == 3 else frame
gray = np.ascontiguousarray(gray, np.float32)
hp = _highpass(gray)
v = np.asarray(v)
if self.n:
deltas = laser_deltas(np.stack([self.prev_v, v]))[1]
# The lasers sometimes miscount at speed (one or both), so check their translation
# against frame-to-frame phase correlation. High-passed frames keep the fixed
# illumination pattern from reading as "no motion" on blank paper.
shift = None
(dx, dy), resp = cv2.phaseCorrelate(self.prev, hp, self.window)
if resp > MIN_PHASE_RESP and np.hypot(*(laser_shift(deltas) - (dx, dy))) > 2:
shift = (dx, dy)
self.stats["image_shift"] += 1
M = self.pose @ relative_motion(deltas, (self.w / 2, self.h / 2), self.rot_gain, shift)
M = self._grow(M)
if self.refine:
M = self._refine(hp, M)
else:
M = self.pose
self.pose, self.prev, self.prev_v = M, hp, v
x0, y0, x1, y1 = self._roi(M)
Ml = (_aff(0, -x0, -y0) @ M)[:2]
wf = self.feather[..., None] if frame.ndim == 3 else self.feather
self.acc[y0:y1, x0:x1] += cv2.warpAffine(frame * wf, Ml, (x1 - x0, y1 - y0), flags=cv2.INTER_LINEAR)
self.wsum[y0:y1, x0:x1] += cv2.warpAffine(self.feather, Ml, (x1 - x0, y1 - y0), flags=cv2.INTER_LINEAR)
self.n += 1
def image(self, white_balance=True, background=255):
covered = self.wsum > 0.05
ws = np.maximum(self.wsum, 1e-6)
out = self.acc / (ws[..., None] if self.ch > 1 else ws)
if white_balance:
out = white_patch(out, covered)
m = covered[..., None] if self.ch > 1 else covered
out = np.where(m, out, background).clip(0, 255).astype(np.uint8)
ys, xs = np.nonzero(covered)
if len(ys):
out = out[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
# Frames arrive transposed and mirrored relative to the page.
return cv2.flip(cv2.transpose(out), -1)
def white_patch(img, mask, pct=97, white=245):
"""Scale each channel so its bright percentile (the paper) maps to `white`."""
img = img.astype(np.float32)
px = img[mask]
ref = np.percentile(px, pct, axis=0)
return img * (white / np.maximum(ref, 1))