- 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>
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.dylibis 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) andcmd/laser. It is used only byHal::HalFree. Our USB/UVC mouse isHal::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 correctionout = slope*in + intercept. The maps come either from/slope.bin /intercept.bin /black.bin /white.binor are computed from the per-device NURBS surfacesnurbsModelBlackandnurbsModelWhite(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].colorDestDarkImagewas 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 isenableColorEnhancement/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::setExposureTime0x1389e90):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.001from 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 everycall [reg+0x3c](thesetExposureTimeMsvtable 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 arepidGainP=1.8,pidGainI=0.01,pidGainD=0.3,pidGoal=240 [V values], alongsideignoreOmvExposureTime. The goal 240 is presumably a brightness target, probably a bright-pixel statistic, for the optical-module calibration tool. A separateThresholdController(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]:
OVMouseDataint 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 inSensorLL::update. - Per-laser calibrated vector [V structure]:
v_i = M_i*(dx,dy)^T + t_i.M_iis a 2x2 matrix at estimator-data +0x84/+0x8c/+0x94/+0x9c (laser 1) and +0xb4/+0xbc/+0xc4/+0xcc (laser 2);t_iis at +0xa4 and +0xd4. It is built in the driver factory (0x12ce020) fromcamTo{Left,Right}MouseAngleandmetersPer{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 by2*L^2visible 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-devicemetersPerLeftMouseUnit/metersPerRightMouseUnitin 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/Max7.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 ispixelSizein 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), andBakerQtfalls 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]:- If
0 < |v1| <= 2and0 < |v2| <= 2(tiny motion on both lasers) -> OK. - Otherwise, with u = normalised (p_right - p_left) from
camToLeft/RightMouseVec(setter 0x12fe570), computed = dot(v1,u) - dot(v2,u)andratio = |d| / max(|v1|,|v2|). Ifratio <= sensitivity-> OK. Else the result is OK only if|d| < 4counts, otherwise invalid (lifted or slipping). The sensitivity is theliftoffSensitivityLaserconfig (default 50; profileASD_SLAMLITEuses 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] AlsolaserDoubleCheckThreshold,laserExpectedRatioMax,laserExpectedNumStepsMin(8000) are calibration-validator settings.
- If
- 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-basedIntensityChecker(Utilities/Core/IntensityChecker.cpp),BookBordermatch 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:
minimumBlenderTranslation0.0075 m (7.5 mm),minimumBlenderRotation0.0785 rad (4.5 deg),maximalBlenderConsecutiveDiscard15,minimumBouncerTranslation0.0015 m,minimumBouncerRotation0.01 rad,maximalConsecutiveDiscard15,minimumCoreOverlap0.2,nFeaturesMin/Max10/1000,ratioThresholdFast0.6,ratioThresholdQuality0.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]:
OVMouseManagerbutton 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 withsetScanButtonPollingInterval(int). PostsScanButtonPressedEvent/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. SettingssingleSwipeEnabled,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 requiresthis[+3] || this[+1].
Proxy write sequence (V), 0x1390a30(reg,val):
- 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.) - If still nonzero after 100 tries: return 1 (error). Otherwise:
- write BD = reg; write BE = val; write B8 = 1. Return 0. No poll after the B8=1 write.
Proxy read sequence (V), 0x1390860(reg):
- Same pre-poll: B8 until 0 (100 tries, 10 ms sleeps). On timeout return without reading.
- write BD = reg; write B8 = 2.
- Poll B8 until it reads 0 (up to 100 reads, no sleep in this loop, tight loop).
- 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.