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