diff --git a/.gitignore b/.gitignore
index 0733dd8..c32ddc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,8 @@ capture/
scan_text/*
!scan_text/records.csv
!scan_text/page.png
+
+# Scan output
+scans/
+btnprobe/
+calib/
diff --git a/FINDINGS.md b/FINDINGS.md
index 6571027..75bfec8 100644
--- a/FINDINGS.md
+++ b/FINDINGS.md
@@ -343,3 +343,62 @@ handling**. There is nothing in it to build a Linux driver from.
frame-to-frame phase correlation, then ECC-refines every frame against the canvas.
- Frames are transposed + mirrored relative to the page; output = flip(transpose(canvas), -1).
- `tools/stitch.py
` -> /page.png. Full 751-frame session stitches cleanly.
+
+## Per-device calibration in EEPROM (read 2026-10-02)
+
+Read-only access is `XU.eeprom_read(addr, n)`, XU selector 3:
+- `01 addr len`: set address and length.
+- `02`: start the read.
+- `03`: returns the next 32 bytes; repeat until done.
+- `05`: end.
+
+The write path uses commands 06/07/09 and is RSA-signed. It is not implemented.
+
+Layout:
+- `0x3000`: u16 blob length (19417 on this unit).
+- `0x3002`: checksum.
+- `0x3006`: a boost binary archive (i386 sizes).
+- `0x7EB5`: 64-byte ID.
+
+`myscan/calib.py` parses it. Values on this unit:
+
+| Field | Value |
+|---|---|
+| ROI | 0,0 – 640,295 |
+| Laser scale (left, right) | 1.922e-5, 1.878e-5 m/count |
+| Laser positions (camera frame) | (11.25, 53.8) mm and (33.55, −0.67) mm |
+| Intrinsics | f = 737.3 px, principal point (317.5, 132.7); 5 distortion coefficients |
+| `pixelSize` | 61.3 µm |
+| `gainR/G/B` | 115 / 89 / 64 (sensor registers 1/2/3) |
+
+Colour calibration is stored as point samples, not as NURBS coefficients:
+- 827 black samples and 873 white samples.
+- Each sample is `u16 x, u16 y, u32 3, u8 b, g, r` in raw-frame coordinates.
+- The white surface matches the illumination falloff of real frames (correlation 0.91–0.95 per channel).
+
+`FlatField` fits cubic B-spline surfaces to the samples. Its default correction divides each frame by the normalised white surface. The app's full `(in − black)/(white − black)·250` is only valid at factory exposure and gains.
+
+Auto-exposure now regulates the 95th percentile of the brightest channel (target 200). The old mean-brightness target let green clip at about 233, which broke the linear correction.
+
+### Image sensor access and colour gains
+
+The image sensor is an **OV7740**: PID 0x77/0x42, MID 0x7F/0xA2.
+
+Two access paths work, both only in scan mode (after D8 E1→99):
+- **MCU proxy**, which is what the app uses:
+ - Read: poll until B8=0, write BD=reg, write B8=2, poll until B8=0, then read BE.
+ - Write: write BD=reg, BE=val, B8=1, then read back to verify.
+- **Bridge path** (UVCControl `ReadSCCB`/`WriteSCCB`): set F1=0x42, then XU selector 2 command 0x81/0x01. The app never uses it.
+
+In mouse mode B8 stays stuck at 2. In standby BE reads 0.
+
+Colour gains: firmware leaves regs 1/2/3 (blue/red/green, 0x40 = 1×) at 64/64/64. The app writes the EEPROM's `gainR/G/B` = 115/89/64 to regs 1/2/3 in that order.
+
+Measured frame B:G:R:
+
+| Gains | B | G | R |
+|---|---|---|---|
+| Firmware default | 6.5 | 14.3 | 10.8 |
+| EEPROM gains | 13.0 | 13.4 | 13.3 |
+
+The EEPROM gains remove the green cast in hardware. `ScanSession(gains=...)` applies them; `scan.py` and `record.py` pass the EEPROM values.
diff --git a/RE_colour_stitch.md b/RE_colour_stitch.md
new file mode 100644
index 0000000..9fefc30
--- /dev/null
+++ b/RE_colour_stitch.md
@@ -0,0 +1,269 @@
+# RE notes: colour, exposure, geometry, lift-off, scan button (Dacuda Scanner Mouse for Mac 2.4)
+
+Static analysis only (no device access). Tags: **[V]** verified in disassembly or strings, **[I]** inferred.
+Addresses are virtual addresses. The main app `Contents/MacOS/Scanner Mouse` and `libSLAMScanSystech.dylib` are
+**i386 thin** Mach-O. `UVCControl.framework` is fat, and its x86_64 slice was used (addresses with "uvc:").
+The app is stripped except for template symbols. I located code through string xrefs and RTTI vtables.
+
+## 0. Scoping facts
+
+- **`libSLAMScanSystech.dylib` is NOT our device. [V]** It is the driver for a different, network-attached
+ "Free"/Systech mouse. It contains ffmpeg and RTSP code plus HTTP/XML commands: `cmd/gain`, `cmd/rgbgain`
+ (``), `cmd/exposure` (`Delay_us`, `Duration_us`) and `cmd/laser`. It is used only by `Hal::HalFree`.
+ Our USB/UVC mouse is `Hal::HalLuckyCat` + `Hal::OVMouseManager` (mouse "LuckyCat", OV bridge), with all
+ stitching and registration code inside the main app (`libs/Core`, `libs/Hal/Driver/...`).
+- **Per-device calibration lives in the mouse's EEPROM (see section 1.4).** The values compiled into the app are
+ only fallbacks, and they are for an LG LSM100 (`productString = EProduct_LG_LSM100`).
+ Anything below tagged "compiled default" is not necessarily what the Hama unit contains.
+
+## 1. Colour / white balance
+
+### 1.1 Sensor and bridge writes (complete list) [V]
+
+`OVMouseManager::sccbWrite(id,reg,val)` is at 0x138b020. It has 36 call sites, and all were enumerated.
+With id=0x42 (image sensor via the bridge proxy BD/BE/B8) the app writes only these registers:
+
+| Sensor reg | Written by | Value |
+|---|---|---|
+| 0x00 | `setGain(double)` 0x1389160; deferred writer 0x13892f0 | OV gain byte, see below |
+| 0x01, 0x02, 0x03 | `setWhiteBalance(int a,int b,int c)` 0x13893e0 (writes at 0x1389732/53/76) | reg1=a, reg2=b, reg3=c |
+| 0x16 and 0x19 | `setImageWindowOffset` / `setYBaseOffset` 0x1389bf0, 0x1389e96, 0x138a7af | window start/stop rows (value derived from `yBaseOffset`) |
+
+**The app never writes sensor reg 0x13 (AEC/AGC/AWB) or any other sensor register.** It relies entirely on
+firmware defaults for AWB and AGC. Bridge-register writes (id=0x88) are D8, ED, EC, 2D, E7, E6, DC, A1..A5 and BD/BE/B8 (proxy).
+
+**Gain encoding (reg 0x00, 0x1389214-0x13892af).** The input is `imagerGain`, a double, clamped to [0,16].
+The OV format has bits 7..4 as doubling stages and bits 3..0 as the fraction:
+
+```
+v = clamp(g,0,16); r = 0
+if v>=16: r|=0x80; g/=2
+if v>=8 : r|=0x40; g/=2
+if v>=4 : r|=0x20; g/=2
+if v>=2 : r|=0x10; g/=2
+r |= (g<2) ? int((g-1)*16) : 0x0F # g=1.0 -> reg0 = 0x00
+```
+
+**Colour gains.** `HalLuckyCat::open` (0x1331ae0) calls `setWhiteBalance(this+0x2fc, +0x300, +0x304)` at 0x133312f.
+These three ints come from the config getters at 0x14452f0, 0x1445360 and 0x14453d0. Their property offsets
+(+0x108, +0x138, +0x168) correspond to `gainR`, `gainG` and `gainB` in the HardwareData config ctor (0x1441300), so:
+
+- sensor reg 0x01 = gainR, reg 0x02 = gainG, reg 0x03 = gainB **in the app's naming** [V for the plumbing].
+- Which physical channel each register controls is [I]. The standard OV7725/7740 map is 0x01=BLUE, 0x02=RED, 0x03=GREEN.
+ If that applies here, the app's "gainR" really lands in the blue-gain register. Test empirically.
+
+**Compiled defaults (LSM100 config ctor 0x1441300) [V]:**
+
+| Name | Default | Note |
+|---|---|---|
+| `gainR` / `gainG` / `gainB` | 150 (0x96) / 89 (0x59) / 64 (0x40) | the app's reg1 / reg2 / reg3 |
+| `imagerGain` | 1.0 | reg 0 = 0x00 |
+| `exposureTimeUs` | 270 | min 100, max 1200 |
+| `yBaseOffset` | 105 | |
+| `laserReadOutDelay` | 0.9 | |
+| `clipOffset` | "8 28 8 12" | ROI margins in px, order not verified [I] |
+
+Starting point for a green cast (**untested [I]**): reg0=0x00, reg1=0x96, reg2=0x59, reg3=0x40. If the
+channels turn out swapped, swap R and B. The proper values are in the EEPROM (1.4).
+
+### 1.2 Software colour correction on decoded frames [V]
+
+The preprocessing pipeline (`Core::PreprocessorFactory`) is, in order: `ImageClipper` -> `NurbsColorBalancerPolicy`
+-> `GammaEnhancerPolicy` -> `Undistorter` (`UndistorterBailey` = lens undistortion) -> FAST corner detector ->
+`ThresholdController`.
+
+- **Colour balancer = `ColorBalancerLinearNurbsScaler`** (`libs/Core/Preprocessor/ColorBalance/...`).
+ It is a per-pixel linear flat-field correction `out = slope*in + intercept`. The maps come either from
+ `/slope.bin /intercept.bin /black.bin /white.bin` or are computed from the per-device NURBS surfaces
+ `nurbsModelBlack` and `nurbsModelWhite` (shading and white reference) in HardwareData ColorParams. This
+ corrects vignetting and colour cast in one step. There are IR and UV variants (`IRBalancer`, `UVBalancer`).
+ Target brightness: `colorDestBrightImage` = **250** [V, default]. `colorDestDarkImage` was not extracted.
+ A simple per-channel white balance alternative: normalise each channel so a white reference maps to 250.
+- **Gamma enhancer** (`GammaEnhancer.cpp`, `enableGammaEnhancement`) builds a LUT by piecewise-linear
+ interpolation (function 0x14b4eb0). The 17-point LSM100 curve at 0x1dc8480/0x1dc84c4 is:
+ x = 0,4,8,16,32,40,48,56,64,72,80,96,112,144,176,208,255
+ y = 6,17,27,44,74,86,96,106,115,123,131,145,157,181,202,224,255.
+ A separate 256-entry "LITEARRAY" LUT exists at 0x1dc8508/0x1dc8908. Which LUT our product uses is unknown.
+ The enabling setting is `enableColorEnhancement` / `enableColors` (UI sliders for brightness and saturation).
+- Hardware-data fields the colour code consumes: `lambda`, `lambdaNormFactor`, `nurbsModelSize`,
+ `nurbsSmoothingFactors`, `nurbsNSmoothingIterations`, `gainR/G/B`, `exposureTimeUs`, `imagerGain`.
+
+### 1.3 Mouse sensor-module variants known to the app [V]
+
+"Scarab", "LuckyCat (Largan)", "LuckyCat (Newmax)", "LuckyCat (Kinko)", "(Kinko, restricted)",
+"(Kinko, r2)", "(Kinko, r2 restricted)". These strings are stored in the HardwareData `info` block.
+
+### 1.4 Per-device calibration in EEPROM (HardwareData) [V for layout, I for blob format details]
+
+Read routine `OVMouseManager::readBinaryData` (0x138c4e0); write routine at 0x138cc90. Both use
+`OVIF_DaEEPROMRead/Write` (uvc: 0x383f/0x380e), which call `-[CameraControlAppDelegate EEPROMRead:withStartAddr:withLength:]` (uvc: 0x1bb9).
+
+EEPROM map:
+
+| Address | Content |
+|---|---|
+| 0x3000 | u16 LE: length N of the blob, valid if 0 < N <= 20000 (0x4E20) |
+| 0x3002 | 4 bytes: checksum (`DaReadBinaryDataChecksum`) |
+| 0x3006 | N bytes: blob |
+| 0x7EB5 | 64 bytes: mouse ID / serial (`ffff...` when erased) |
+
+Blob = `boost::archive::binary_iarchive` (`HardwareDataSerializer::fromRawData`, 0x14c3731) holding, in order,
+`RoiParams`, `SensorParams`, `UndistortionParams`, `ColorParams`, `InfoParams`. There is also an "archiveVersion" check,
+which produces "Unknown archive version" and "Device contains an old version of UserData" errors.
+Field names (from the serializer string table 0x1b19c02..):
+
+- Sensor: `exposureTimeUs`, `imagerGain`, `laserReadoutDelay`, `metersPerLeftMouseUnit`, `metersPerRightMouseUnit`,
+ `camToLeftMouseAngle`, `camToLeftMouseVec`, `camToRightMouseAngle`, `camToRightMouseVec`, `gyroDegPerStep`, `gyroBias`,
+ `yBaseOffset`.
+- Roi: `roiX0`, `roiY0`, `roiX1`, `roiY1`.
+- Undistortion: `clipOffset`, `focalLength`, `principalPointX/Y`, `distModel`, `distCoefficients`, `tiltH`, `pixelSize`.
+- Color: `gainR/G/B`, `lambda`, `lambdaNormFactor`, `nurbsModelBlack/White`, `nurbsModelSize`, `nurbsSmoothingFactors`, `nurbsNSmoothingIterations`.
+- Info: `fingerprint`, `calibrationTime`, `avgReprojErrorIntrinsic`, `avgReprojErrorHomography`, `archiveVersion`, mouse-model enum, product/vendor/odm strings.
+
+**EEPROM read protocol (uvc: 0x1bb9) [V].** It uses XU selector 3, a 33-byte buffer. "ReadBuf" means SET_CUR sel 3 followed
+by GET_CUR sel 3 (uvc: 0x25bb). "WriteBuf" means SET_CUR only (uvc: 0x2680). Requests are 0x21/0xA1, wValue 0x0300,
+wIndex 0x0600, wLength 33:
+
+```
+ReadBuf [01, addr_lo, addr_hi, len_lo, len_hi, ...] # set address+length
+WriteBuf[02, ...] # start
+repeat ceil(len/32): ReadBuf[03, ...] -> response bytes 1..32 = next 32 data bytes
+WriteBuf[05, ...] # end
+```
+
+The write protocol (0x1a34) is cmd 1 (addr, len), 6, then 7 per 32-byte chunk, and 9 to finish. The `len < 0x7fff` check is in the write path.
+Do NOT use the write path casually. The EEPROM holds the calibration, and writes are signed (RSA, `:/calib_rsa.pub.txt`).
+
+**Strong recommendation:** use the selector-3 read to dump 0x3000..0x3006+N from the user's own mouse. That yields
+this unit's real gainR/G/B, imagerGain, exposureTimeUs, laser scale and positions, pixelSize, lens model and NURBS
+white/black surfaces. I did not touch the device, so none of the blob format is confirmed on real data.
+
+## 2. Exposure
+
+- Formula [V] (`OVMouseManager::setExposureTime` 0x1389e90): `E6 = floor(ms*49.23 + 0.5)` (the 49.23 is a double constant at 0x1cfaf80), clamped 0..255 by the bridge byte.
+- The app calls it only from `HalLuckyCat::open` (0x1332e04): `ms = exposureTimeUs * 0.001` from HardwareData. Default
+ 270 us -> E6 = 13; limits 100..1200 us -> E6 5..59. **I found no runtime caller that varies exposure.** I checked
+ the only direct caller and every `call [reg+0x3c]` (the `setExposureTimeMs` vtable slot) in the binary; none is
+ exposure-related. [V for the callers, I for the conclusion "no AEC loop for LuckyCat".]
+- The PID settings exist but look calibration-only [I]. In the DacudaSettings ctor (0x1470ff0), next to
+ `OpticalModuleValidator`, they are `pidGainP`=1.8, `pidGainI`=0.01, `pidGainD`=0.3, `pidGoal`=240 [V values],
+ alongside `ignoreOmvExposureTime`. The goal 240 is presumably a brightness target, probably a bright-pixel
+ statistic, for the optical-module calibration tool. A separate `ThresholdController`
+ (`gainP`=0.01, `gainI`=0.001, `minThreshold`=20, `maxThreshold`=150) tunes the FAST detector threshold, not exposure.
+- So for our own AE: the vendor target brightness is ~240 on the bright part, with exposure kept in 100..1200 us
+ (E6 5..59). Our 0..255 range is wider than the vendor ever uses. LED strobe time is the exposure.
+- Laser read position, bridge reg DC [V] (0x138a1d0): `DC = floor(min(pos,0.9)*128 + 0.5)`, so the default 0.9 gives 0x73
+ (115). The app refuses it while streaming.
+
+## 3. Stitching geometry
+
+Pipeline: record -> `SensorLL::update` (0x12f5c40) -> `LaserCheckerGeometric::check` (0x12fe6b0) ->
+`PoseEstimatorLL::estimatePose` (0x12f14c0, vtable 0x1d9a208+).
+
+- **Record fields used [V]:** `OVMouseData` int fields at +0x74/+0x78 = laser1 dx, dy and +0x80/+0x84 = laser2 dx, dy
+ (cvtsi2sd at 0x12f5d91.. and 0x12f14df..). The Sq fields sit at +0x7c and +0x88 and are not read in `SensorLL::update`.
+- **Per-laser calibrated vector [V structure]:** `v_i = M_i*(dx,dy)^T + t_i`. `M_i` is a 2x2 matrix at estimator-data
+ +0x84/+0x8c/+0x94/+0x9c (laser 1) and +0xb4/+0xbc/+0xc4/+0xcc (laser 2); `t_i` is at +0xa4 and +0xd4. It is built in
+ the driver factory (0x12ce020) from `camTo{Left,Right}MouseAngle` and `metersPer{Left,Right}MouseUnit` / `laserScaling`.
+ The pose (x, y, phi) is then a 2-point rigid fit: translation from the displacements and rotation from the
+ difference of the two displacement components perpendicular to the laser-laser baseline, divided by the
+ squared baseline length (division by `2*L^2` visible at 0x12f17d9).
+ Laser dy is negated in the checker adapter at 0x12fe620 (`(dx,-dy)` for both lasers).
+- **Scale [V]:** `laserScalingLuckyCat` = float 0x379f6230 = **1.9e-5 m per laser count** (Driver config ctor 0x1422b40, compiled default).
+ The per-device `metersPerLeftMouseUnit` / `metersPerRightMouseUnit` in EEPROM override it [I]. That is ~1337 counts/inch.
+- **Laser positions [V, LSM100 compiled default; per-device in EEPROM]:** left (0.025, 0.063) m, right (0.025, -0.014) m, both angles 0.
+ So the lasers are 77 mm apart along y, 25 mm from the camera axis in x.
+ Our empirical values give a baseline of 1.9e-5/4.4e-4 = 43 mm and ~0.057 mm/px, so the Hama unit
+ differs from the LSM100 defaults, as expected. Use the EEPROM values.
+- **Camera pixel size:** the validator expects 75..79.4 um per pixel (`geomExpectedPixelSizeMin/Max` 7.5e-5..7.94e-5 m), i.e.
+ 320-339 dpi native, for LG modules. `gridUnitInMeters` = 1.058333e-3 (1/24 inch), `subsamplingFactorOM` = 1.2.
+ The authoritative number is `pixelSize` in the EEPROM.
+- **Orientation:** my empirical result (frames transposed + mirrored: laser dx ~ image y, laser dy ~ image x with sign flip) is consistent
+ with the dy negation and axis-swapped matrices above. The exact image flip in the app is applied by `mirrorConfiguration`
+ (profile int, default 1 / user 2 in WING profile) plus the clip and undistort stages. Not resolved.
+- **Output DPI:** user setting `scanResolution` (enum index, default 3) -> `Core::setResolution(int dpi)` (0x112fbd0), and `BakerQt` falls
+ back to "native camera resolution" if undefined. The index->dpi table was not found (labels probably in .qm resources). Not resolved.
+
+## 4. Lift-off / frame rejection
+
+- **`LaserCheckerGeometric::check(l1, l2)` (0x12fe6b0) [V logic]:**
+ 1. If `0 < |v1| <= 2` and `0 < |v2| <= 2` (tiny motion on both lasers) -> OK.
+ 2. Otherwise, with u = normalised (p_right - p_left) from `camToLeft/RightMouseVec` (setter 0x12fe570), compute `d = dot(v1,u) - dot(v2,u)` and
+ `ratio = |d| / max(|v1|,|v2|)`. If `ratio <= sensitivity` -> OK. Else the result is OK only if `|d| < 4` counts, otherwise
+ **invalid (lifted or slipping)**.
+ The sensitivity is the `liftoffSensitivityLaser` config (default 50; profile `ASD_SLAMLITE` uses 21), passed to the ctor 0x12fe4f0 and stored at +0x6c.
+ A raw ratio can never exceed 2, so the value is probably pre-scaled or the property is another one. The units are unresolved. [I]
+ Also `laserDoubleCheckThreshold`, `laserExpectedRatioMax`, `laserExpectedNumStepsMin` (8000) are calibration-validator settings.
+- **Second checker (0x12fd330, from `SensorLL::update`) [V structure, I meaning]:** keeps circular buffers of the last laser integer deltas
+ (4 channels), and tests max |delta| against fixed limits (3, 1, 8, 9, 12) with a state flag. It logs "LiftOff1" / "LiftOff2". It looks like a
+ stuck/static-sensor test.
+- **Surface-quality bytes (record bytes 14/15, i.e. Sq fields) and byte 16 / flag bits:** I found no thresholds applied to them in the
+ app. They are copied into `SensorDataLL` (`laser1Sq`, `laser2Sq`) but unused by the pose estimator or checker I decoded. [V for
+ "not in SensorLL::update", I for "not used elsewhere"]. Lift detection is geometric (laser disagreement) plus Core states: `ScanningState` -> `LiftoffState`
+ ("Stop after lift-off"), image-based `IntensityChecker` (`Utilities/Core/IntensityChecker.cpp`), `BookBorder` match failure, "The mouse was lifted off when
+ starting the scan". Stop reasons in the UI: Buffer Full, lift off, user stop, USB capacity exceeded.
+- Other thresholds: `minimumBlenderTranslation` 0.0075 m (7.5 mm), `minimumBlenderRotation` 0.0785 rad (4.5 deg),
+ `maximalBlenderConsecutiveDiscard` 15, `minimumBouncerTranslation` 0.0015 m, `minimumBouncerRotation` 0.01 rad,
+ `maximalConsecutiveDiscard` 15, `minimumCoreOverlap` 0.2, `nFeaturesMin/Max` 10/1000, `ratioThresholdFast` 0.6, `ratioThresholdQuality` 0.7.
+
+## 5. Scan button
+
+- **Register [V]:** bridge reg 0xBC, bit 0 (active high). `OVIF_GetScanBtnStatus` (uvc: 0x389a) edge-detects against the previous sample: returns
+ **0x11 on a press edge (0->1), 0x10 on a release edge (1->0)**, 0/1 for steady state, -1 for the first sample, 0x99 on read error.
+- **Polling [V]:** `OVMouseManager` button thread (loop at 0x138b4d0, call at 0x138b79e). Default interval stored at this+0xbc is 0x64 = **100 ms**
+ (store at 0x1390681, so I assume the ctor), changeable with `setScanButtonPollingInterval(int)`. Posts `ScanButtonPressedEvent` / `ScanButtonReleasedEvent`.
+ The loop takes a mutex around the register read, which is shared with video control, so reads are serialised.
+- **Semantics [I, from strings and config]:** `ControlWindow::scanButtonPress` -> "Start scan, initiated with scan button press (not known yet if
+ single swipe or regular scan)". Press = start. Settings `singleSwipeEnabled`, `singleSwipeTime` = 400 ms: a press held longer than that is
+ "single swipe" (scan runs while held and stops on the release event, "Got ScanButton Released event / Single swipe is active"). A short press is a
+ regular scan that continues after release and stops on the next press ("user stop") or on lift-off, buffer full or USB error. Pressed events are ignored
+ while a modal dialog is open. Also: "Press scan button to begin scanning".
+- The HID report byte0 bit7 we already see carries the same button state.
+
+## 6. What I could not determine
+
+- This unit's real EEPROM contents (gains, scales, laser positions, pixel size, NURBS surfaces) and the boost-binary blob layout. Reading the EEPROM
+ (section 1.4) is the way to get them.
+- Which of reg 1/2/3 is physically R/G/B on this sensor. Test empirically.
+- The meaning and scale of `liftoffSensitivityLaser`; how Sq bytes 14/15, byte 16 and the flag bits are used (nothing found).
+- The scan-resolution index -> dpi table, and the exact final image flip.
+- Whether the firmware turns on AWB/AEC itself (the app never writes sensor reg 0x13).
+
+## 7. Sensor proxy protocol (SCCB 0x42 via bridge BD/BE/B8)
+
+Source: `OVMouseManager::sccbWrite` 0x138b020, low-level proxy write 0x1390a30, proxy read 0x1390860, generic read dispatcher ~0x13904f0.
+"Bridge reg" = `OVIF_SCCBRead/Write(0x88, reg, ..)`, i.e. XU sel 2 cmd 0x80/0x00 (`UVCRead/WriteUSBReg`).
+
+**State flags (V)** in OVMouseManager: +1 = standby (set at 0x138afaf after D8=00 and D8 reads 0xFF; cleared 0x138b459 after D8=E0 enable-mouse),
++2 = mouse mode, +3 = scan streaming (set 0x138aa5e after VideoRun, cleared 0x13864b3 in stopVideo). +1 is NOT cleared by startVideo, so it stays 1 during scan.
+
+**Gate (V):**
+- Proxy read 0x1390860 and proxy write 0x1390a30 both start with `if (this[+1]==0) return` (no bridge traffic at all).
+ So **in mouse mode the app silently does nothing for sensor accesses**. Only standby or scan (+1==1) works.
+- `sccbWrite(0x42)` additionally requires `this[+3] || this[+1]`.
+
+**Proxy write sequence (V), 0x1390a30(reg,val):**
+1. Poll bridge B8 up to 100 times; break when B8 == 0. Between polls it sleeps via boost `sleep_until(now + 10000 us)` (10 ms, I for the unit being us). (First read happens before any sleep.)
+2. If still nonzero after 100 tries: return 1 (error). Otherwise:
+3. write BD = reg; write BE = val; write B8 = 1. Return 0. **No poll after the B8=1 write.**
+
+**Proxy read sequence (V), 0x1390860(reg):**
+1. Same pre-poll: B8 until 0 (100 tries, 10 ms sleeps). On timeout return without reading.
+2. write BD = reg; write B8 = 2.
+3. Poll B8 until it reads 0 (up to 100 reads, **no sleep** in this loop, tight loop).
+4. **Result = bridge reg BE** (single read, the value returned as the sensor register value).
+No BE write for reads. No F1 write.
+
+**Verify and retry in sccbWrite(0x42) (V):** write(reg,val) via 0x1390a30, then read-back via 0x1390860 and compare with val. On mismatch:
+loop up to 10 times: sleep 10 ms, rewrite, sleep, read back, compare. Returns 0 on verified match, 1 on failure. The app's sensor writes therefore always read back.
+
+**Which path for id 0x42 (V):** the proxy only. OVMouseManager calls `OVIF_SCCBRead/Write` exclusively with id 0x88; it never passes 0x42.
+The UVCControl path (`ReadSCCB/WriteSCCB` uvc: 0x12ce/0x13a6: if id != cached g_sccbID, bridge write F1=id, then XU sel 2 cmd 0x81 / 0x01) exists in the
+framework but is unused by the app. The app never writes bridge F1 anywhere (no `0xF1` immediate in OVMouseManager or OVInitUVC).
+
+**Implication for your observation (I):** your sequence (BD=reg, B8=2, poll B8, read BE) matches the app's read exactly, and the app too works only in standby/scan.
+Differences to try: (a) the app writes nothing else first, so the zero BE may be real, e.g. the sensor not yet powered/clocked, or reg values genuinely 0 in standby;
+(b) try the same read while in scan mode (D8=E1 acked 0x99), where the app also uses it (it writes gain/colour gains at open in standby and window regs 0x16/0x19, and verifies each);
+(c) do the pre-poll (B8==0) before writing BD; (d) as a control, do a write of a known register (e.g. 0x00 gain, B8=1) and read back with the above protocol;
+(e) an F1=0x42 plus XU cmd 0x81 read is an alternative the firmware supports (UVCControl) but the app never exercises it, so it is untested by the vendor app.
+I cannot say from static analysis why BE reads 0 in standby.
diff --git a/myscan/calib.py b/myscan/calib.py
new file mode 100644
index 0000000..338fcff
--- /dev/null
+++ b/myscan/calib.py
@@ -0,0 +1,132 @@
+"""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 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("= 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("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))
+
+
+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))
diff --git a/myscan/session.py b/myscan/session.py
new file mode 100644
index 0000000..5041499
--- /dev/null
+++ b/myscan/session.py
@@ -0,0 +1,79 @@
+"""Scan-mode session: enter scan mode, stream decoded frames with auto-exposure,
+and always restore mouse mode on exit."""
+import os
+import time
+
+import cv2
+import numpy as np
+
+from .frame import fix_and_extract
+from .xu import XU
+
+
+def quiet_libjpeg():
+ """libjpeg warns on every frame (the firmware's headers); send fd 2 to /dev/null
+ and return a Python stream for the real stderr."""
+ import sys
+ sys.stderr = os.fdopen(os.dup(2), "w")
+ os.dup2(os.open(os.devnull, os.O_WRONLY), 2)
+
+
+def bright_level(im, pct=95):
+ """Brightness of the paper: the `pct` percentile of the brightest channel."""
+ px = im[::4, ::4]
+ px = px.reshape(-1, 3).max(1) if px.ndim == 3 else px
+ return float(np.percentile(px, pct))
+
+
+class ScanSession:
+ def __init__(self, video="/dev/video4", target=200, exposure=None, xu=None, gains=None):
+ """gains: sensor colour gains for regs 1/2/3 (the EEPROM's gainR/G/B), or None."""
+ self.video, self.target, self.fixed, self.gains = video, target, exposure, gains
+ self.x = xu or XU(video)
+ self.e6 = exposure or 40
+ self.cap = None
+
+ def __enter__(self):
+ x = self.x
+ 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, self.e6)
+ and x.start_scan()[0]):
+ x.enable_mouse()
+ raise RuntimeError("failed to enter scan mode")
+ if self.gains and not x.set_color_gains(*self.gains):
+ print("warning: could not set sensor colour gains", flush=True)
+ self.cap = cv2.VideoCapture(self.video, cv2.CAP_V4L2)
+ self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
+ self.cap.set(cv2.CAP_PROP_CONVERT_RGB, 0)
+ return self
+
+ def __exit__(self, *exc):
+ if self.cap is not None:
+ self.cap.release()
+ self.cap = None
+ self.restored = self.x.enable_mouse()[0]
+ return False
+
+ def frames(self, color=True):
+ """Yield (t, n, image, record); image is BGR (or grayscale) 640x296."""
+ t0, n = time.time(), 0
+ flag = cv2.IMREAD_COLOR if color else cv2.IMREAD_GRAYSCALE
+ while True:
+ ok, f = self.cap.read()
+ if not ok:
+ continue
+ clean, rec = fix_and_extract(f.tobytes())
+ im = cv2.imdecode(np.frombuffer(clean, np.uint8), flag)
+ if im is None or rec is None:
+ continue
+ yield time.time() - t0, n, im, rec
+ # Proportional auto-exposure every 3 frames (brightness is ~linear in E6). It
+ # regulates the bright end (paper) of the strongest channel, so no channel clips;
+ # clipping would break the linear flat-field correction.
+ if self.fixed is None and n % 3 == 0:
+ level = bright_level(im)
+ if level > 1:
+ new = int(min(255, max(2, round(self.e6 * (self.target / level) ** 0.7))))
+ if new != self.e6 and self.x.mcu_write_verified(0xE6, new):
+ self.e6 = new
+ n += 1
diff --git a/myscan/stitch.py b/myscan/stitch.py
index 0723aed..866767c 100644
--- a/myscan/stitch.py
+++ b/myscan/stitch.py
@@ -16,6 +16,7 @@ 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):
@@ -50,6 +51,16 @@ def relative_motion(d, center, rot_gain=ROT_GAIN, shift=None):
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
@@ -57,22 +68,31 @@ def _feather(h, w):
class Stitcher:
- def __init__(self, frame_shape, margin=600, refine=True, rot_gain=ROT_GAIN):
- self.h, self.w = frame_shape
- self.refine, self.rot_gain = refine, rot_gain
+ """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], np.float32)
- self.wsum = np.zeros_like(self.acc)
+ 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
+ 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):
- corners = np.array([[0, 0, 1], [self.w, 0, 1], [0, self.h, 1], [self.w, self.h, 1.0]]).T
- q = (M @ corners)[:2]
+ 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
@@ -81,25 +101,37 @@ class Stitcher:
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.acc.shape[::-1]
+ self.size = self.wsum.shape[::-1]
shift = _aff(0, left, top)
self.pose = shift @ self.pose
return shift @ M
return M
- def _refine(self, frame, 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.
- Mi = np.linalg.inv(M)[:2]
- acc = cv2.warpAffine(self.acc, Mi, (self.w, self.h), flags=cv2.INTER_LINEAR)
- ws = cv2.warpAffine(self.wsum, Mi, (self.w, self.h), flags=cv2.INTER_LINEAR)
+ 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 = np.where(mask, acc / np.maximum(ws, 1e-6), 0).astype(np.float32)
+ 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, frame.astype(np.float32), W, cv2.MOTION_EUCLIDEAN,
+ _, 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:
@@ -113,33 +145,55 @@ class Stitcher:
self.stats["refined"] += 1
return M @ np.linalg.inv(W3)
- def add(self, frame, deltas):
- frame = frame.astype(np.float32)
+ 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:
- # At speed the lasers can disagree with each other (one over-counts), so check
- # the translation against frame-to-frame phase correlation.
+ 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, frame, self.window)
- if resp > 0.2 and np.hypot(*(laser_shift(deltas) - (dx, dy))) > 2:
+ (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(frame, M)
+ M = self._refine(hp, M)
else:
M = self.pose
- self.pose = M
- self.prev = frame
- self.acc += cv2.warpAffine(frame * self.feather, M[:2], self.size, flags=cv2.INTER_LINEAR)
- self.wsum += cv2.warpAffine(self.feather, M[:2], self.size, flags=cv2.INTER_LINEAR)
+ 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, background=255):
- out = np.where(self.wsum > 0.05, self.acc / np.maximum(self.wsum, 1e-6), background)
- out = out.clip(0, 255).astype(np.uint8)
- ys, xs = np.nonzero(self.wsum > 0.05)
+ 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))
diff --git a/myscan/xu.py b/myscan/xu.py
index 6ba501d..3c0effa 100644
--- a/myscan/xu.py
+++ b/myscan/xu.py
@@ -102,3 +102,62 @@ class XU:
def scan_button(self):
return self.bridge_read(0xBC) & 1
+
+ # --- EEPROM (per-device calibration), read-only; protocol from UVCControl EEPROMRead (uvc: 0x1bb9).
+ # Selector-3 commands: 01 = set addr+len, 02 = start read, 03 = next 32 bytes, 05 = end.
+ # (The write path uses 06/07/09 and is deliberately not implemented.)
+
+ def _buf(self, *cmd, read=False):
+ req = bytes(cmd) + bytes(33 - len(cmd))
+ self._query(SEL_BUF, _SET_CUR, req)
+ if read:
+ return self._query(SEL_BUF, _GET_CUR, bytes(33))
+
+ def eeprom_read(self, addr, length):
+ self._buf(0x01, addr & 0xFF, addr >> 8, length & 0xFF, length >> 8, read=True)
+ self._buf(0x02)
+ out = bytearray()
+ try:
+ while len(out) < length:
+ out += self._buf(0x03, read=True)[1:33]
+ finally:
+ self._buf(0x05)
+ return bytes(out[:length])
+
+ # --- image sensor (OV7740 at SCCB 0x42) through the MCU proxy BD/BE/B8, as the app's
+ # OVMouseManager does (0x1390a30 write, 0x1390860 read). Only answers in standby/scan mode.
+
+ def _proxy_idle(self):
+ for _ in range(100):
+ if self.bridge_read(0xB8) == 0:
+ return True
+ time.sleep(0.01)
+ return False
+
+ def sensor_read(self, reg):
+ if not self._proxy_idle():
+ return None
+ self.bridge_write(0xBD, reg)
+ self.bridge_write(0xB8, 2)
+ for _ in range(100):
+ if self.bridge_read(0xB8) == 0:
+ break
+ return self.bridge_read(0xBE)
+
+ def sensor_write(self, reg, value, tries=10):
+ """Write and read back with retries, like sccbWrite(0x42, ...)."""
+ for _ in range(tries):
+ if not self._proxy_idle():
+ return False
+ self.bridge_write(0xBD, reg)
+ self.bridge_write(0xBE, value)
+ self.bridge_write(0xB8, 1)
+ time.sleep(0.01)
+ if self.sensor_read(reg) == value:
+ return True
+ return False
+
+ def set_color_gains(self, reg1, reg2, reg3):
+ """OV7740 colour gains: reg 0x01 = blue, 0x02 = red, 0x03 = green (0x40 = 1x).
+ The app writes the EEPROM's gainR/G/B to regs 1/2/3 in that order."""
+ return all(self.sensor_write(r, v) for r, v in ((1, reg1), (2, reg2), (3, reg3)))
diff --git a/tools/btnprobe.py b/tools/btnprobe.py
new file mode 100644
index 0000000..49d0dc6
--- /dev/null
+++ b/tools/btnprobe.py
@@ -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 /. 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()
diff --git a/tools/record.py b/tools/record.py
index 2eca294..af6b605 100644
--- a/tools/record.py
+++ b/tools/record.py
@@ -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",
diff --git a/tools/scan.py b/tools/scan.py
new file mode 100644
index 0000000..d845b25
--- /dev/null
+++ b/tools/scan.py
@@ -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 /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()
diff --git a/tools/stitch.py b/tools/stitch.py
index 02a6778..b3d3534 100644
--- a/tools/stitch.py
+++ b/tools/stitch.py
@@ -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 /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}")