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:
erik
2026-10-02 11:13:59 +00:00
co-authored by Claude Opus 5.5
parent e1c1012c1c
commit e70d56d957
11 changed files with 919 additions and 82 deletions
+59
View File
@@ -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.