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

20 KiB

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.