Files
myscan/EEPROM.md
T
erikandClaude Opus 5.5 026aa6c894 Global page assembly with loop closures and lens undistortion
The frame-by-frame stitcher accumulated drift, so later passes landed rotated
and offset, doubling text. myscan/pagegraph.py instead solves all frame poses
together: ECC odometry between consecutive frames (bridging single corrupt
frames), SIFT+RANSAC loop closures between overlapping passes, and a robust
Gauss-Newton pose graph. Loop matches must agree over the whole overlap, which
rejects matches on repeated text. Off-page frames and frames that disagree
with the mosaic are left out of the render.

calib.Undistort applies the EEPROM lens model (OpenCV k1..k3, p1, p2 plus
the tilt homography, as decoded in EEPROM.md); up to 8.8 px at the corners.

scan.py registers frames on a worker thread so capture no longer drops
frames, then assembles the page after the stop press (~11 s for a full
page). stitch.py uses the new assembly by default; --live keeps the old one.

EEPROM.md documents the full calibration blob layout, checksum and signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:06:45 +00:00

21 KiB

EEPROM contents (Hama myScan / Dacuda LuckyCat)

This is a field-by-field map of the per-device calibration blob, from one unit's dump (calib/eeprom_3000.bin). For the read protocol, see FINDINGS.md ("Per-device calibration in EEPROM"). For the app side, see RE_colour_stitch.md section 1.4.

Status tags:

  • [P]: parsed by myscan/calib.py.
  • [D]: decoded by hand from the bytes; the value is clear.
  • [G]: guessed field name or meaning.
  • [?]: unknown.
  • [V]: verified in disassembly or decompilation; the function address is given as evidence.

EEPROM map

Address Size Content
0x3000 2 u16 LE blob length N (19417 on this unit). Valid if 0 < N <= 20000 (0x4E20) [V 0x138c4e0, read of 0x3000 at 0x138c743, bound check 0x138c7a0]. The write path rejects N >= 0x4FB7 [V 0x138cc90]
0x3002 4 u32 LE standard CRC-32 (zlib polynomial) over the N blob bytes, signature included. Verified in Python against the dump: 0x3cd95f42 [D]. The app does not recompute or compare it on read: it is only a cache key (see below) [V 0x1335430]
0x3006 N boost::archive::binary_iarchive blob (i386 sizes), then a 256-byte RSA signature at its tail
0x7EB5 64 mouse ID: raw 64-byte buffer, handled as a string. Read by OVMouseManager::readMouseId (0x138e1e0 [V], read at 0x138e4fa), written by writeMouseId(const char*) (0x138db20 [V], write at 0x138dd80). Not yet read from the device. The HalLuckyCat wrapper is 0x1334a60
< 0x3000 ? The app never touches anything below 0x3000. A scan of every call to OVIF_DaEEPROMRead/Write (the stubs 0x17ff6fa / 0x17ff700) in the main binary gives only the callers 0x138c4e0 (0x3000, 0x3006), 0x138d410 (0x3002), 0x138e1e0 (0x7EB5), 0x138cc90 (writeBinaryData: a single write starting at 0x3000 for header + checksum + blob, call at 0x138cf3f; the caller builds the whole buffer, the CRC is not computed there) and 0x138db20 (0x7EB5). UVCControl.framework just wraps the raw read/write (EEPROMRead:withStartAddr:withLength:). So whatever is below 0x3000 is firmware-side only [V by absence]

Everything the app reads from the EEPROM is therefore: length, checksum, blob and the 64-byte ID.

Blob layout

All offsets below are relative to the blob start (EEPROM 0x3006). The whole blob parses with no gaps (a stand-alone parser consumes exactly bytes 0 .. N-256, then the signature follows).

Stream = boost::archive::binary_iarchive, library version 7 (boost ~1.42), i386 sizes. Each class is written as: tracking u8, class version u8, then its members, in the order of serialize<binary_iarchive>. All five classes here have 00 0e = tracking 0, class version 14. Fixed C arrays (T[n]) are written as u32 count + elements. std::vector<T> is u32 count, u32 item_version, elements. A class that is first met inside a container (std::vector, boost::array) writes its own 00 00 (tracking 0, version 0) once. The app accepts class versions 7..15 only (else WrongArchiveVersionException, "Incompatible archive version!", [V] 0x14cc1a0 and the other four serialize functions).

Serialize functions ([V], serialize<binary_iarchive>): Info 0x14cc1a0, Color 0x14cde20, Undistortion 0x14cefe0, Sensor 0x14cfcd0, Roi 0x14d0990. Names come from the property-tree writers: Undistortion 0x14c0510, Sensor 0x14c1300, Roi 0x14c23a0, Info 0x14c2bc0, Color 0x14bedb0 (all [V]); the umbrella HardwareData has members color @0x00, undistortion @0x68, sensor @0xF4, roi @0x144, info @0x158 (0x14be690 [V]).

Header

Offset Value Meaning
0x00 u32 22, serialization::archive boost signature [D]
0x1A 07 04 04 04 08 library version 7; sizeof int/long/float/double = 4/4/4/8 [D]
0x1F u32 1 endianness check [D]

RoiParams (0x23: 00 0e) [V 0x14d0990, names 0x14c23a0]

Five plain i32 in this order, no version conditions.

Offset Member offset Type Name Value
0x25 +0x00 i32 yBaseOffset 105
0x29 +0x04 i32 roiX0 0
0x2D +0x08 i32 roiY0 0
0x31 +0x0C i32 roiX1 640
0x35 +0x10 i32 roiY1 295

yBaseOffset was previously unknown. It is the first Roi integer (105).

SensorParams (0x39: 00 0e) [V 0x14cfcd0, names 0x14c1300]

Read order below. "Version" is the stream class version. This unit is 14.

Offset Member offset Type Name Needs ver Value
0x3B +0x08 f64 metersPerLeftMouseUnit any 1.922e-5
0x43 +0x10 f64 metersPerRightMouseUnit any 1.878e-5
0x4B +0x00 f64 laserReadoutDelay >= 9 0.0
0x53 +0x18 f32 camToLeftMouseAngle >= 14 0.0
0x57 +0x1C f64[2] (count 2) camToLeftMouseVec (m) any (0.01125, 0.0538)
0x6B +0x2C f32 camToRightMouseAngle >= 14 0.0
0x6F +0x30 f64[2] (count 2) camToRightMouseVec (m) any (0.03355, -0.00067)
(absent) +0x40 f64 gyroDegPerStep >= 15 not in this blob
(absent) +0x48 f64 gyroBias >= 15 not in this blob

So the gyro fields do not exist for this unit: it is a version-14 blob, and the gyro pair needs 15. The two angles are float (setters movss 0x14c73e0 / 0x14c7450, getters fld dword 0x14c7400 / 0x14c7470) [V]. imagerGain and exposureTimeUs are ColorParams members, not Sensor members. yBaseOffset is in Roi.

UndistortionParams (0x83: 00 0e) [V 0x14cefe0, names 0x14c0510]

Offset Member offset Type Name Needs ver Value
0x85 +0x00 f64 focalLength (px) any 737.328
0x8D +0x08 f64 principalPointX (px) any 317.546
0x95 +0x10 f64 principalPointY (px) any 132.733
0x9D +0x18 i32 distModel (Utilities::Enums::EDistortionModel) >= 8 4
0xA1 +0x1C f64[5] (count 5) distCoefficients any 0, -0.403169, 1.3805e-4, 2.6490e-3, 0
0xCD +0x44 f64[8] (count 8) tiltH any see below
0x111 +0x84 f64 pixelSize (m) any 6.1305e-5

tiltH = 1.002744, 0.007833, -0.985882, -0.000164, 1.006565, -0.435809, -1.17e-6, 2.47e-5. Eight numbers for a 3x3 homography whose ninth entry is implicit (=1). Usage: see "How it is used" below.

ColorParams (0x119: 00 0e) [V 0x14cde20, names 0x14bedb0]

The sample sets are std::vector<std::vector<SamplingPointAbstract<short,unsigned char,3>>> (member +0x00, two inner vectors: black, white). Framing, in stream order:

Offset Content
0x11B 00 00 class info of outer vector
0x11D u32 2 (outer count), u32 0 (item version)
0x125 00 00 class info of inner vector
0x127 u32 827 (count), u32 0 (item version), 00 00 class info of the sample type
0x131 827 samples: i16 x, i16 y, u8[3] colour (u32 count 3 + b, g, r). The first sample also carries a 00 00 class info for boost::array between y and the count (13 bytes); the rest are 11 bytes
0x24BD u32 873 (count), u32 0 (item version), then 873 samples of 11 bytes (x, y, 3, b, g, r)

Sample arrays are SamplingPointAbstract<short, uchar, 3>: the two coordinates are signed shorts. Scalar members follow:

Offset Member offset Type Name Needs ver Value
0x4A47 +0x0C u32 exposureTimeUs >= 8 440
0x4A4B +0x18 i32 gainR >= 8 115
0x4A4F +0x1C i32 gainG >= 8 89
0x4A53 +0x20 i32 gainB >= 8 64
0x4A57 +0x10 f64 imagerGain >= 9 1.5
0x4A5F +0x24 f64[3] (count 3) lambda (one per channel) >= 10 1.921e12, 5.193e7, 5.098e7
0x4A7B +0x3C f64 lambdaNormFactor >= 11 6025357260.0
0x4A83 +0x44 i32 nurbsModelSize >= 12 5
0x4A87 +0x48 f32[3] (count 3) nurbsSmoothingFactors >= 12 50, 50, 50
0x4A97 +0x54 i32 nurbsNSmoothingIterations >= 12 100
0x4A9B +0x58 i32[4] (count 4) clipOffset >= 14 8, 12, 8, 12

The names are from the property-tree writer 0x14bedb0 (offsets confirm the mapping). Corrections to older notes: imagerGain is the 1.5 (not "lambda"), lambda is the 3-vector (not "lambdaNormFactor"), the 6.0e9 double is lambdaNormFactor, the "u32 5" is nurbsModelSize (a scalar, not a list), and 4, 8, 12, 8, 12 is the clipOffset int[4] with its count (so clipOffset = 8, 12, 8, 12). clipOffset is read as a QList<int> by 0x14c5600 and used in QualityTrackManager setup (0x115bc70 [V]): it is subtracted from the ROI to size the overlap estimator, height_px = (roiY1 - roiY0) - clip[1] - clip[3], width_px = (roiX1 - roiX0) - clip[0] - clip[2] (then times pixelSize), so the order is {left, top, right, bottom} margins in pixels [G for the edge names, V for the pairing]. The app's built-in default (HWSetup ini, string 8 28 8 12) has the same shape.

InfoParams (0x4AAF: 00 0e) [V 0x14cc1a0, names 0x14c2bc0]

The first 16 bytes of the Info member are a GUID (Data1 u32, Data2 u16, Data3 u16, Data4 u8[8]; the array is written with u32 count 8). The previous "8 bytes before fingerprint" are Data1..Data3, and the old "fingerprint" is Data4.

Offset Member offset Type Name Needs ver Value
0x4AB1 +0x00 u32 fingerprint Data1 any 0x84F3094A
0x4AB5 +0x04 u16 Data2 any 0x7B88
0x4AB7 +0x06 u16 Data3 any 0x49C6 (UUID version 4)
0x4AB9 +0x08 u8[8] (count 8) Data4 any 86 57 3a 6a 38 5e 6f 68
0x4AC5 +0x10 u32 calibrationTime (time_t) any 0x5269BEBF = 2013-10-24 23:35:27 UTC
0x4AC9 +0x14 f32 avgReprojErrorIntrinsic (px) any 0.158
0x4ACD +0x18 f32 avgReprojErrorHomography (px) any 0.133
0x4AD1 +0x1C u32 product (Utilities::Enums::EProduct; not in the ptree writer) >= 13 4
0x4AD5 +0x20 u32 archiveVersion >= 11 (else it takes the class version) 14

The old "enum 14" is archiveVersion, which equals the class version here. The old "archiveVersion 4" at +0x1C is the product enum: its accessors are 0x14c7880 (set) and 0x14c7890 (get) [V]; HalLuckyCat::open (0x1331ae0) copies it into the driver config (config+8) and validates it via 0x1300ff0, which maps it to a profile resource :/mouse_driver_profiles/<EProduct_*>.xml. For archives older than 13 the product reads as 0, and loadHardwareData (0x1335430, "CONVERT_LSM200_HACK") rewrites it to 1 for old units whose calibrationTime falls in a window around Jan-May 2012 (compared with QDate(2012,1,1) and QDate(2012,5,1)) and whose pixelSize is below a threshold, and it also replaces camToLeft/Right vectors with fixed values (0.0141, 0.0525) and (0.036, 0) there [V 0x1335430, exact date logic not worked out]. This unit (2013) is unaffected. open picks DriverFactoryLG for product in {2, 13, 14}, otherwise DriverFactoryLL; product 4 (this unit) therefore uses DriverFactoryLL. The profile resources are named EProduct_ADI_WIRED/WIRELESS, ASD_SLAMBASIC/SLAMBASIC2/SLAMGYRO/SLAMLITE/WIRELESS, FOX_WIRED/WIRED_GYRO/WIRELESS, LG_LSM100/150/200/300, Passport, WING_WIRED; the numeric order of the enum (moc table) is not readable in the binary, so which product is 4 is [?]. Full UUID: 84f3094a-7b88-49c6-8657-3a6a385e6f68 (the unit's unique id, matches the old "fingerprint"). None of the product/vendor/ODM text strings are in this archive; those live in the HWSetup ini struct (0x1441300: productString EProduct_LG_LSM100, vendorString EVendor_LG, odmString EOdm_Chicony, exposureTimeUsMin/Max, skewedImageGain).

Signature

Offset Value Meaning
0x4AD9 256 bytes, to the end of the blob RSA-2048 PKCS#1 v1.5 signature with SHA-1 (DigestInfo 3021300906052b0e03021a05000414) over blob bytes [0, N-256), i.e. everything before the signature, header excluded [D]. Verifies against calib/calib_rsa.pub.pem (public exponent 35, an unusual value) with cryptography.

Integrity

Checksum (0x3002) [D, matched against the dump; not seen in disassembly]

CRC-32 (the zlib crc32(), polynomial 0xEDB88320, init/xorout 0xFFFFFFFF) over all N blob bytes including the 256-byte signature, stored little-endian. On this unit: zlib.crc32(blob) == 0x3cd95f42 and the EEPROM bytes at 0x3002 are 42 5f d9 3c. The sum, Adler-32, SHA-1 and MD5 variants were tried and do not match.

Use in the app (HalLuckyCat::loadHardwareData, 0x1335430 [V]): the checksum is read via readBinaryDataCheckSum (0x138d410) and turned into a cache file name <PathsPool::logDir()>/<crc as 8 hex digits>.bin.

  • Checksum 0 or 0xFFFFFFFF (unprogrammed EEPROM): skip the cache and read the blob from the mouse.
  • Otherwise the app tries the cache file ("Read HardwareData from file.", parser 0x14ba4e0). On failure it logs "Error reading HardwareData from file. Try reading from mouse" and reads the blob from the EEPROM (ovtReadHardwareData, 0x137e0e0). After a read from the mouse it writes the cache (0x14bc150). So the CRC is a cache key; a wrong value would only cause a cache miss or a stale cache hit, not a rejection. No CRC computation happens on the read path (the CRC-32 table is at 0x1c0c730, used by a zlib routine at 0xeff640).

Signature [V]

hal::verifyRSASignature(data, dataLen, sig, sigLen) is 0x12ff5d0: it loads the PEM public key from the Qt resource :/calib_rsa.pub.txt (PEM_read_bio_PUBKEY) and runs EVP_sha1 + EVP_DigestUpdate + EVP_VerifyFinal, i.e. RSASSA-PKCS1-v1_5 with SHA-1. It is called from ovtReadHardwareData (0x137e0e0) after HardwareDataSerializer::fromRawData (0x14c3720). fromRawData returns the archive stream position after InfoParams; that is the length of the signed part. The acceptance rule is:

ok = (archiveVersion < 11) or (0 < consumed < N and verifyRSASignature(blob, consumed, blob + consumed, N - consumed))

So blobs with archiveVersion >= 11 must carry a valid signature over blob[0 .. consumed) (everything before the signature; the 6-byte EEPROM header is not included). Older archives (< 11) are accepted unsigned. This unit: consumed = N - 256 = 19161, the signature is the last 256 bytes, and it verifies: calib/calib_rsa.pub.pem (RSA-2048, public exponent 35) recovers 0001ff..ff00 3021300906052b0e03021a05000414 b5dab6a61406d37057c5381e242b7424f22a952e and SHA1(blob[:19161]) = b5dab6a61406d37057c5381e242b7424f22a952e. The key is copied to calib/calib_rsa.pub.pem. (Verification only. The private key is not in the app; writes are produced by the factory tool.)

How the calibration is used

Undistortion map (generateMapFromParams, 0x120e160 [V]; per-pixel kernel 0x10f1750 [V])

The app builds the K matrix and the distortion vector from the Undistortion members and fills two float maps mapX, mapY (one entry per output pixel). Accessors: focal 0x14c5d00, ppX 0x14c5d10, ppY 0x14c5d20, distCoefficients 0x14c5d90 (raw pointer to member +0x1C), tiltH 0x14c5e00 (member +0x44), pixelSize 0x14c5e30.

K = [[f, 0, cx], [0, f, cy], [0, 0, 1]]            (fx = fy = focalLength)
H = tiltH row-major, 3x3, H[2][2] = 1.0            (h00 h01 h02 h10 h11 h12 h20 h21)
M = inv(K) * inv(H)                                (two cvInvert, one cvGEMM; OpenCV 2.4.3)
for every output pixel (x, y):
    (X, Y, W) = M * (x, y, 1);      x' = X/W,  y' = Y/W
    r2 = x'^2 + y'^2
    radial = 1 + c0*r2 + c1*r2^2 + c4*r2^3
    xd = x'*radial + 2*c2*x'*y' + c3*(r2 + 2*x'^2)
    yd = y'*radial + c2*(r2 + 2*y'^2) + 2*c3*x'*y'
    mapX = K00*xd + K01*yd + K02 ;  mapY = K10*xd + K11*yd + K12
  • distCoefficients = OpenCV order [k1, k2, p1, p2, k3] ([V] from the kernel: indices 0, 1, 4 radial; 2, 3 tangential). The unit's values: k1 = 0, k2 = -0.40317, p1 = 1.38e-4, p2 = 2.65e-3, k3 = 0. This is the standard OpenCV 5-parameter model (the same code path as OpenCV's initUndistortRectifyMap with newCameraMatrix = H*K in the sense dest = H * K * normalised).
  • tiltH is a homography in pixel space, applied after K: forward direction is dest = H * K * (x_d-undistorted normalised coords), which is what M = inv(K)*inv(H) encodes. It is not a rotation on normalised rays. For this unit H is close to identity (shift about (-0.99, -0.44) px, scale 1.003 / 1.007, tiny perspective terms).
  • distModel is never read in this build: its getter 0x14c5d40 has no callers (vtable slot only). The EDistortionModel enum has a name map (0x14cadf0) but the map builder always uses the 5-coefficient model above. So value 4 is cosmetic here [V by absence of callers]. What 4 means in the enum is [?].
  • The cvUndistortPoints / cvInitUndistortMap error text "Distortion coefficients must be 1x4, 4x1, 1x5..." (0xb65990, 0xb6db50) is plain OpenCV 2.4.3 code; it takes the same 5-vector.

Sensor set-up and misc (HalLuckyCat::open, 0x1331ae0 [V])

open copies the blob into the driver config (config = param_2) with the HardwareData accessors (color 0x14c4ca0 @+0x00, undistortion 0x14c4cb0 @+0x68, sensor 0x14c4cd0 @+0xF4, roi 0x14c4cf0 @+0x144, info 0x14c4d10 @+0x158):

Blob field Goes to Evidence
exposureTimeUs config+0x2F0 = float(exposureTimeUs) * 0.001f (so ms: 440 -> 0.44 ms) getter 0x14c5470 called at 0x13328a8; mul at 0x13328ba (constant 0.001f at 0x1cf9a94). This confirms the u32 440
imagerGain config+0x2F4 (double) getter 0x14c54a0 at 0x13328d5
gainR/G/B config+0x2FC/0x300/0x304 getters 0x14c54d0 / 0x14c54e0 / 0x14c54f0
roi x0, y0, x1, y1 config+0x2D4..0x2E0 (defaults 0, 0, 640, 296) 0x14c7680
yBaseOffset config+0x2E4 getter 0x14c76d0
product config+8 0x14c7890

The reverse direction (config -> blob, used when the app writes/derives a new calibration) uses the setters 0x14c5460 (exposure, int(ms / 0.001)), 0x14c5480, 0x14c54b0, 0x14c7620, 0x14c76c0, 0x14c7880.

Colour correction (NURBS surfaces)

The blob stores samples, not surfaces. FUN_011e7950 ([V], 3 passes, one per colour channel c = 0, 1, 2) turns the black and the white sample set into B-spline surfaces (library UCBspl: SplineSurface, MGsystem multigrid, LSsystem least squares):

for c in (0, 1, 2):
    x[i], y[i] = sample.x, sample.y ; z[i] = sample.colour_byte[c]     # sample = 8-byte struct: i16 x, i16 y, u8[3]
    sys = MGsystem(1, 1, nurbsModelSize = 5, x, y, z)                  # 0x11fc2d0: multilevel B-spline, levels grow as 2^k
    if nurbsSmoothingFactors[c] != 0:
        sys.setSmoothing(nurbsSmoothingFactors[c])                     # 0x11fdb70 (double)
        sys.iterate(nurbsNSmoothingIterations, 3)                      # 0x11fda30
    surfaces.append(SplineSurface(sys))                                # 0x11fd660

So nurbsModelSize (5) is the number of refinement levels of the multilevel B-spline, nurbsSmoothingFactors[c] is the per-channel smoothing weight (50 here) and nurbsNSmoothingIterations (100) is the iteration count of the smoothing solver; accessors 0x14c55a0 (modelSize), 0x14c55d0 (factors pointer), 0x14c55f0 (iterations) have the single caller 0x11e7950 [V]. The black surface and the white surface are evaluated per pixel by the colour balancer (NurbsColorBalancerPolicy); the linear map out = slope*in + intercept and the target 250 (colorDestBrightImage) are in RE_colour_stitch.md. The sample sets themselves are the nurbsModelBlack/White members (the vector-of-vectors at ColorParams +0x00). The three passes use colour-byte index 0, 1, 2 of each sample, which is the stored order b, g, r.

lambda / lambdaNormFactor: the library message "LSsystem::calcLambda(): e_norm is zero when calculating lambda" (string at 0x1b106c4) suggests [G] that lambda is the regularisation weight computed by the least-squares solver for a fit (LSsystem::calcLambda, balances data fit against the smoothness term), stored once per channel at calibration time (1.92e12, 5.19e7, 5.10e7). That is an inference from the library name only; the strings have no direct code reference in the decompilation. lambdaNormFactor (6.025e9) normalises it. The only reader is accessor 0x14c5570, which returns lambda[0] / lambdaNormFactor (= 318.9 on this unit) [V]. Its two callers are the tracker set-ups FUN_011543e0 (0x115460b, FastTrackLKM parameters) and FUN_0115bc70 (0x115c341, QualityTrackManager), where it is passed as one double of the parameter struct next to pixelSize. The factory-line settings key colorExpectedNormalizedLambda1Min (read at 0x1422b40, also 0x14800d0) looks like a lower bound for this normalised value, i.e. a calibration quality gate [G]. What the tracker does with the value is [?]. lambda[1] and lambda[2] have no direct accessor call: the pointer accessor 0x14c5530 has only a vtable data reference.

Open questions

  • Read the 64-byte ID at 0x7EB5 from the device (not done; static analysis only so far).
  • Which product the EProduct value 4 is (the moc enumerator table is not in the binary in readable form).
  • The EDistortionModel enum value 4 (its name map is built at 0x14cadf0), though the value is never read.
  • What the tracker (FastTrackLKM, QualityTrackManager) does with lambda[0]/lambdaNormFactor; lambda[1]/lambda[2] are never read.