Files
myscan/RE_colour_stitch.md
T
erikandClaude Opus 5.5 e70d56d957 Live button-driven scanning with EEPROM calibration
- ScanSession: scan-mode context manager with bright-pixel auto-exposure and
  sensor colour gains; tools/scan.py scans on the scan button with live stitching
- Read-only EEPROM access and calib.py parser (laser geometry, intrinsics,
  colour gains, black/white flat-field samples); flat-field correction in Stitcher
- OV7740 sensor access via the MCU proxy; colour gains from EEPROM fix green cast
- Stitcher: colour support, high-passed registration (robust on blank paper)
- RE notes on the Mac app's colour, exposure, stitching and button handling

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

270 lines
20 KiB
Markdown

# 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.