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>
This commit is contained in:
erik
2026-10-02 11:13:59 +00:00
co-authored by Claude Opus 5.5
parent e1c1012c1c
commit e70d56d957
11 changed files with 919 additions and 82 deletions
+5
View File
@@ -11,3 +11,8 @@ capture/
scan_text/*
!scan_text/records.csv
!scan_text/page.png
# Scan output
scans/
btnprobe/
calib/
+59
View File
@@ -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 <dir>` -> <dir>/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.
+269
View File
@@ -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`
(`<Red><Green><Blue>`), `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.
+132
View File
@@ -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("<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)),
"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))
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))
+79
View File
@@ -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
+84 -30
View File
@@ -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))
+59
View File
@@ -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)))
+80
View File
@@ -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 <out>/. 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()
+16 -38
View File
@@ -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)
with s:
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
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, e6,
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()])
# 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} "
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
n += 1
cap.release()
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",
+119
View File
@@ -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 <outdir>/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()
+9 -6
View File
@@ -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 <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("--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}")