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