Skip to content

Add TCA compensation for Panasonic RW2 files - #22302

Draft
jsmucr wants to merge 27 commits into
darktable-org:masterfrom
jsmucr:rw2-tca-compensation
Draft

jsmucr wants to merge 27 commits into
darktable-org:masterfrom
jsmucr:rw2-tca-compensation

Conversation

@jsmucr

@jsmucr jsmucr commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

WIP

Turns out that Claude may be very well capable of extracting the required knowledge from various sources and a couple of RW2 files processed with SILKYPIX.

@jsmucr
jsmucr marked this pull request as draft September 16, 2026 12:24
@TurboGit

Copy link
Copy Markdown
Member

Wouldn't this have been better as an issue for the moment?

jsmucr and others added 15 commits September 18, 2026 06:29
Records what the current tree does with Panasonic RW2 lens correction,
identifies the regression introduced by 47c2237 (auto-selecting the
embedded-metadata correction method for RW2, which is distortion-only
and drops TCA), and outlines the follow-on work to decode the CA data
present in Exif.PanasonicRaw.0x011b.

Working document for the follow-on agent, not a permanent design doc.
Rigo's parseca.c four-checksum layout for 0x011b verified against
the sample RW2 (all four match). Homeister's ExifTool forum 9366
post (retrieved via web.archive.org) supplies the interior structural
map: 4-radial-zone model with N1..N4 at words [11],[4],[16],[17], on/off
flag at [14], selector at 0x011a. The sample's ratios (1.0, 0.833,
0.667, 0.333) match the MFT-sensor pattern he reported.

Coefficient roles within Homeister's three interior "parts" are still
undecoded and remain the RE gap. Doc restructured to separate verified
structure from remaining unknowns, prunes the earlier "two 16-word
blocks" hypothesis that we now know is wrong, updates the image.h
struct sketch to zone-based, and adds a shooting recipe for the RW2
corpus available on the developer's machine.
Log the outcome of the coefficient-fitting stage. The piecewise-linear
4-zone hypothesis with the 8 smooth words as knot heights and a single
global scaling factor does not fit the measured CA, even with all
knot permutations and a per-file gain: aggregate residual RMS 0.024 to
0.036 px against a null RMS of 0.037 px, and the per-file gains flip
sign three to six times across the nine files with a 50x range in
magnitude. Monomial-in-r and Catmull-Rom variants also fail.

Measurement improvements captured: parabolic peak fit on the gradient
profile with a peak-dominance filter, bilinear profile sampling and a
same-feature integer-peak check, which lowers the per-bin half-vs-half
RMS on 5 of 6 file/channel pairs on the three test files. The
aggregated point cloud is 37,381 edges across 9 files, dumped to
/tmp/rw2_tca/measurements.npz.

Word[8]'s anti-correlation with the sign of measured R-G in 9/9 files
still holds and is the strongest single-word signal in the tag, but
does not extend to a coherent zone-height model with the other seven
smooth words.

The section is honest about the negative outcome: the 8-smooth-words
piecewise-linear model with a global k is not the decode. Alternative
hypotheses (three per-channel distortion polynomials, word[2] as a
lens-family key rather than a coefficient) are listed as candidates
for the next attempt, alongside the corpus additions that would
resolve the ambiguity (in-camera CA-toggle pair, distortion-toggle
pair, SILKYPIX Pro null-corrected reference).
GX80 mirror corpus (nine files across the same lens/focal set as the
G9, at f/5.6) passes all four checksums on every file and shows the
same structural layout: 0x011a=2, word[14]=256 flag, 4-zone model with
identical 0.333/0.667/0.833/1.0 knot ratios, empty 0x0119 on the L
45-150, high-order flag bits on the PL 12-60, flag=1 on the primes.

The 4 radii scale with body sensor size: GX80 N1=2888 vs G9 N1=3276,
ratio 0.882, matching the ratio of half-diagonals within 0.5%. The
ratios themselves are body-invariant.

Word[8]'s anti-correlation with the sign of measured R-G radial
displacement replicates cleanly on the GX80: 9 of 9 files, no
exceptions. The two G9 files that were sign-ambiguous due to small
measured amplitude come out unambiguous on the GX80.

Removes the 'different body' item from 'What would resolve it' since
that experiment is now done, replaces it with a note on the missing
in-camera CA/distortion toggles (verified against DVQP1406ZA and by
hand on both bodies).
Session 3 tried to decode 0x011b coefficients by measuring geometric
distortion (much larger signal than CA) against the paired camera
JPEG. Two hard findings:

- The L 45-150 JPEGs are NOT distortion-corrected. Camera does not
  apply either 0x0119 (empty on this lens) or 0x011b to the in-camera
  JPEG. Residual 0.15 px RMS is registration noise, so fitting 0x011b
  against L 45-150 measurements is meaningless.
- On files where 0x0119 is populated, its Rigo polynomial predicts
  distortion shape well (R^2 0.75-0.96) but the required scale
  factor swings from +0.90 to +5.24 to -0.95 within the same 12-60
  zoom sweep. The 12-60's flag byte word[7] has high-order bits
  (0xF0, 0xB7, 0x8D at 12/25/60mm) that Rigo and 47c2237 both
  discard; primes all have 0x0001 flag and k close to 1. Strong
  hypothesis: the high byte of word[7] encodes a per-focal-length
  strength scaler.

Implications: 47c2237's distortion output on Leica DG 12-60 is
likely mis-scaled by factors of 2-5 as a latent bug on top of the
Lensfun-TCA regression. Decoding 0x011b is blocked pending 0x0119's
full decode.
Session 4 tested H1-H5 for a hidden per-focal-length strength scaler
in word[7]'s high byte and finds all five hypotheses fail. Fresh
k_only measurements are stable across bodies for the same tag data,
but no simple function of word[7] (linear, subfield, LUT, whole
int16) predicts the k pattern (best R^2 = 0.15). Nor does any of the
untouched 0x0119 words (word[2, 3, 6, 9, 10, 13]) in single or
two-way linear combinations.

The wild k=+5.24 (25mm) and k=-0.95 (60mm) on the PL 12-60 that
motivated the search are measurement artifacts of translation-only
SIFT registration on files where the tag's polynomial predicts small
amplitude (peak 2 px and 15 px). A joint fit of polynomial plus
linear-in-r residual collapses k toward 1 on the eight
strong-signal files (median 1.02) and shows a small (~0.5%)
unmodeled radial dilation dominating the two anomalous files. Any
model of the r-monotonic residual with a wrong-shape polynomial
lands at a wild k.

Practical consequence: session 3's implication (b) - that
47c2237's Panasonic distortion path may be silently mis-scaled -
is retracted. The distortion math applies the tag's polynomial the
way the tag intends. The default-method-selection regression covered
elsewhere in this document is unchanged.
…0x011b words

Adobe DNG Converter emits a three-plane WarpRectilinear opcode on every
RW2 in the corpus, giving us Panasonic's own per-channel correction as
ground truth instead of pixel measurements. Fit D_R = R_plane - G_plane
and D_B = B_plane - G_plane against the smooth words in 0x011b.

D_R decodes cleanly: dropping w[2] (a per-lens family baseline), w[26]
and w[29] leaves six words that predict all four D_R.k_r{0..3} at
R^2_LOGO 0.97-0.99 under leave-one-group-out cross-validation, where
each held-out group is both bodies of a shared (lens, focal). D_B's
low orders (k_r0, k_r1) fit at R^2_LOGO 0.99; higher orders do not
generalize, because Adobe's B-plane coefficients differ 2-4x between
bodies at the same lens and focal even after LOGO training.

Section adds the ground-truth definition, sanity check against Rigo's
0x0119 polynomial (matches with a small per-file Adobe framing
constant), the LOGO scores of F1..F5 and reduced-word variants, the
6x4 coefficient matrix for D_R and the 6x2 low-order-only matrix for
D_B, and a stub of the follow-on darktable patch shape. No code
changes.
Session 6 runs the honest cross-corpus test session 5 asked for.
16 RW2 samples from DC-S5, DC-G9M2, DC-GH5 and DMC-GX8, sourced from
raw.pixls.us, none of which was in the G9+GX80 training corpus.

Structural findings (Rigo checksums, word[14] flag, Homeister radii)
survive every third-body sample. Two structural corrections fall out:
DC-S5 uses a full-frame-specific zone-radii pattern (1.0/6-7/4-7/2-7,
not the MFT 1.0/5-6/4-6/2-6), and DC-G9M2 ships 0x011b without an
0x011a selector at all. Both matter for the darktable parse.

The R-plane C_R decode reproduces the sign of the R-vs-G displacement
on 69 of 78 signal-carrying bins across the four new bodies -- the
same match rate the same pipeline gives on the training corpus. It
over-predicts absolute pixel magnitude by ~10x on every body,
including on the training corpus itself, so this gap is a property of
Adobe's DNG opcode vs raw sensor CA and not a failure of C_R to
generalize across bodies.

Retested session 5's B-plane high-order gap: no single or pair of
discrete words closes it. Adding four discrete words to the base six
lifts D_B.k_r3 LOGO R^2 from 0.08 to 0.854 (marginal), but D_B.k_r2
tops out at 0.774. At 10 predictors on 18 files this is closer to
overfit than a decode; the gap is still open.
Session 7 pairs each RW2 in a six-file subset (Sigma 16 and PL 12-60 at
12mm and 60mm, on G9 and GX80) with its camera JPEG, coarse-registers
raw to JPEG with a partial affine, and measures R-G and B-G radial
displacements in both with the parabolic sub-pixel fit already used
against the raw. The camera JPEG carries essentially no CA (jpeg_RG
within +/- 0.02 px on every signal bin), so the correction the camera
applied equals the raw's own CA magnitude.

Comparing that applied correction to the C_R prediction:

- median-of-per-file-medians ratio = 11.48
- range 6.18 - 14.07 across six files (2.28x spread, cv = 30%)
- per-body median: G9 = 13.29, GX80 = 8.05
- sign matches on 26 of 26 signal bins
- direct read of Adobe's WarpRectilinear opcode (without the C_R fit)
  gives the same 4x - 30x ratios, confirming the gap is Adobe's opcode
  vs the raw, not a C_R decode artifact

Outcome is the session brief's 'systematic mismatch (3-10x)' branch.
A single global scale of 1/11.48 is right on the order but leaves
per-file error at about 2x either way. Recommend not shipping C_R
naively; understand Adobe's opcode magnitude first.

Report goes in as a new subsection under 'Coefficient decode results'.
No code changes.
Session 7 measured that Adobe's WarpRectilinear opcode over-corrects
the CA the camera JPEG actually applies by ~11x on a six-file subset.
Session 8 extends that measurement to the full 18-file corpus, then
asks whether the session-5 decode is structurally correct (same
coefficients, wrong scale) or whether it needs refitting against the
JPEG-derived applied correction.

Two refit shapes were tried against 207 (file, radial-bin) target
points:

- per-file poly4 fit then linear regression against words, mirroring
  session 5. LOGO R^2 is negative on all eight polynomial
  coefficients because the applied correction (0.01-0.15 px) is too
  small to condition four polynomial degrees of freedom per file
  against per-bin measurement noise.
- direct 24-DOF pixel-space fit on all bins at once. Train R^2 = 0.808
  on R and 0.530 on B; under LOGO both go strongly negative (-6.11
  and -0.99) and per-file RMS degrades to 0.38 px on the worst file.
  Ridge does not help.

The scale factor generalises. Dividing session-5 C_R output by
K = 11.48 delivers per-file median RMS 0.028 px on R and 0.021 px on
B, worst-case 0.058 px and 0.034 px, with 96% of R and 75% of B
signal bins matching sign of the measured applied CA. Per-body K is
G9 10.4 / GX80 7.3, showing real body-dependent spread inside the
[7, 13] range, but no in-file signal keys on body cleanly. Ship
K = 11.48 constant.

Adds a JPEG-referenced decode section under 'Coefficient decode
results' and a JPEG-target patch formula block. Session 5's
Adobe-DNG-referenced matrices stay as historical reference with a
pointer to the session 8 K = 11.48 divisor.

Scripts at /tmp/rw2_tca/step19_{01..07}_*.py.
Static analysis of SILKYPIX Developer Studio 8 SE's SILKYPIX64.dll to
try to lift Panasonic's own algorithm for reading Exif.PanasonicRaw.0x011b.
Records what was recovered and what the analysis could not reach.

Recovered:
  - the MSVC RTTI chain used to find IslZTiffExifPanasonic and
    IslBRawReadPanasonic0 vtables, and the seven vtable slots that
    Panasonic uniquely overrides,
  - the class-field offset each Panasonic private-IFD tag maps to, from
    the tag->stub table at RVA 0x17accc4. Tag 0x11b lives at
    obj + 0x129a8,
  - the five functions in .text that touch obj + 0x129a8 (via .pdata
    RUNTIME_FUNCTION entries for accurate bounds), including the 88 KB
    private-IFD parser at 0x4f3ca0..0x50950d,
  - explicit no-hit results for the candidate CA constants (K = 11.48,
    the Homeister zone radii {0.333, 0.667, 0.833, 1} in both MFT and
    full-frame-S forms).

Not recovered:
  - the exact word subset and polynomial form the parser applies. The
    parser is 88 KB of MSVC-optimised code with the tag switch flattened
    into an indirect-jump table, and lifting it further needs a
    decompiler,
  - the DefaultLensInfo.spd and DefaultParameters.spd container format,
    which is a proprietary 'ISL Multi purpose file format'. Suspected
    home of a per-lens attenuation that would explain the K factor,
    but not confirmed.

Conclusion: session 8's decode is not refuted. Ship it as is. No
body-conditional scaling in the darktable patch; the per-body K spread
seen in session 7 probably lives in the .spd data, not in code.

Notes doc gets the understanding, not the decompiled listing. Nothing
from SILKYPIX enters the tree.
Session 9 stopped at the 88 KB private-IFD parser because linear
disassembly could not lift its body. This session got Ghidra 12.1.3
headless working against SILKYPIX64.dll and used it to answer, for
each of the five session-9 questions, what the binary is able to
say. Two settled, two answered with strong negative evidence, one
open. Session 8's decode still ships.

New empirical findings:
- The polynomial is not in the private-IFD parser. All nine touches
  of obj+0x129a8 in FUN_1804f3ca0 are reset/failure paths
  (memset the CA container to zero on error).
- The CA container is 5512 bytes (0x1588), not the 64-byte payload
  we thought. It has sub-blocks and cached derived state, mapped
  partially from the class constructor at 0x4f07a0.
- SILKYPIX does not compute csum mod 0xFFEF. The modulus appears
  three times in the whole DLL, none in Panasonic code. Either
  Rigo's checksum is not the validator SILKYPIX uses, or SILKYPIX
  trusts the tag unchecked. Recommendation: keep parsing the tag
  even if Rigo's checksum fails; log-only.
- IslEISDevelopDemosaicPanaCA is a plugin, not a C++ class -- no
  RTTI type descriptor, no static factory table pairing class id
  0x1216 with a constructor. The tag-0x11b decoder and polynomial
  evaluator live inside a factory-instantiated module whose entry
  point is not reachable from static class-hierarchy walks.
- Property id 0xa052 = "ColorAberration" is the pipeline hook.
  Dispatched via FUN_18074e0f0 -> FUN_1810ceb30(..., 0xa052).
  That is the trailhead for a future dynamic-tracing session.

The section captures which Ghidra scripts were written, which files
were produced, what the reconciliation with session 8 amounts to
(session 8 stays; per-body K spread stays unexplained), and honest
bounds on what remained un-lifted.
Lifted the CA polyline builder at RVA 0x10ceb30 (FUN_1810ceb30),
called from the property-key dispatcher with prop_id=0xa052. It
produces a 9-knot IslZCnvPolyLine sampled by the render side, not
a polynomial. Knots come from payload short indices 1..7 (radii)
and 8..14 (values), each adjusted by seven int32 offsets fetched
from property 0xa020; radii are normalised by half the raw-image
diagonal and values by 1000. Boundary knots are (0, 1) and
(2, knot[6].y). Verified against x86 disassembly.

Session 8's six-word set overlaps SILKYPIX's use of words 8, 10
and 12 (as knot values 1, 3, 5), but SILKYPIX reads all of
payload[1..14] and none past 14, so words 20/23/27 are not
directly consumed here. The K = 11.48 factor is not a code
constant, but its order of magnitude matches SILKYPIX's implicit
value scale (short/1000). The polyline representation is not
equivalent to a quartic, which explains session 8's LOGO refit
failing.

The raw-tag-to-property mapping (inside the 88 KB private-IFD
parser) and the R/B split on the render side remain unlifted, so
session 8's polynomial with K = 11.48 stays the shipping
recommendation. Recommends: when both are lifted, switch the
darktable path to a monotone spline evaluator on seven knots.
Session 11 had a shape-of-code right but the label wrong. The
property-id table at RVA 0x1a117d0..0x1a11a70 in SILKYPIX64.dll
maps 0xa050 -> Distortion, 0xa051 -> ColorAberration, 0xa052 ->
Shading. Session 10 read it shifted by one entry and session 11
inherited the shift. The 9-knot polyline that session 11 called
the CA descriptor is the shading descriptor: the dispatcher at
0x74f148/74f204/74f244 calls the builder for 0xa052/0xa053/0xa054
which are Shading and its two camera-correction variants, not CA.

Session 12 also verified that 0xa051 (ColorAberration) never
appears as an immediate anywhere in .text and its name string has
no LEA references from code, so SILKYPIX 8 SE reaches Panasonic's
CA correction through neither the property-id nor the property-name
path -- if it reaches it at all. Property 0xa020, which session 11
called a per-lens offset table, is named 'Encryption Key' in that
same property-id table; its origin was not lifted.

The 58 KB FUN_1804f3ca0 was decompiled fully (raising
DecompileOptions.setMaxPayloadMBytes past the 50 MB default that
had silently truncated session 10), and contains no immediate for
any of the CA/shading/distortion property ids, and no immediate
for tag 0x11b. Its argument shape is (handle, struct*, small_int)
and its body accesses fixed struct fields rather than iterating IFD
entries, so session 10's identification of it as the private-IFD
parser is not supported by the code.

Plugin id 0x1216 = IslEISDevelopDemosaicPanaCA is confirmed via
the plugin-name switch at RVA 0x547756, but there is no static
edge from that id to a plugin factory.

The shipping recommendation is unchanged: keep session 8's
polynomial fit. Do not port session 11's spline; it would replace
darktable's imperfect CA with a wrong-object-class shading
correction.
Add a 'For the implementing agent' section right after the immediate
workaround, containing the concrete recipe another agent needs to write
the darktable patch: files to edit, struct extension, exif.cc parser
with the four-checksum validator (log-only per session 12), CA
evaluation in _init_coeffs_md_v2 including full C_R and C_B_lo matrices
and K_JPEG divisor, sensor-format handling, auto-select policy, testing
guidance, release-notes draft, and honest bounds on the fit.

Add a 'Optional TODOs (post-shipping)' section before References,
listing G9 firmware RE, Adobe Camera Raw RE, B-plane higher-order
decode, third-body K validation, SPD file format work, dynamic tracing
paths, and fit-robustness expansion. None block shipping.

Update the TL;DR to reflect the state after 12 sessions: decode
converged with LOGO R^2 0.97-0.99 on R, max RMS residual 0.06 px, cross-
body validated on 6 Panasonic bodies. The older 'Where the code needs
to change' section from session 2 remains below as an audit trail.
@jsmucr
jsmucr force-pushed the rw2-tca-compensation branch from 2fad937 to 2b79ef9 Compare September 18, 2026 04:29
Since 47c2237, Panasonic RW2 files with a populated DistortionInfo
default to embedded-metadata correction, but the Panasonic branch of
_init_coeffs_md_v2 wrote the same radial multiplier into all three RGB
channels, silently dropping the Lensfun-based transverse chromatic
aberration correction that used to apply.

Decode Exif.PanasonicRaw.0x011b alongside 0x0119. The 64-byte payload
is stored as 32 signed int16 LE on dt_image_correction_data_t::panasonic;
Rigo's four-checksum is validated and logged on mismatch but not gated
on, since SILKYPIX itself does not enforce it and some newer bodies
write valid coefficients without a matching 0x011a selector. Six words
at indices 8, 10, 12, 20, 23 and 27 feed a fitted 4x6 matrix that
reproduces Adobe DNG Converter's WarpRectilinear R plane on top of a
global 11.48 divisor calibrating the output against the in-camera JPEG.
B goes through a 2x6 matrix on its two lowest-order terms; the higher
orders did not decode from the six-word set and are left at the G-plane
value. Also mirror the tag to Exif.Image.0xf11b on TIFF/DNG round-trip,
in the same TIFF private range as the 0xf119 alias 47c2237 added
for the distortion tag.

Decode corpus was 34 RW2s across six bodies (G9, GX80, S5, GH5, G9M2,
GX8) with WarpRectilinear ground truth from Adobe DNG Converter and
magnitude calibration on the paired G9 + GX80 subset with camera JPEGs.
Worst per-file RMS residual against the JPEG-measured CA is 0.058 px
on R and 0.034 px on B, both under one Bayer super-pixel. Sign matches
the JPEG on 96% of R signal bins. See RW2_TCA_investigation.md for the
decode audit trail.
The prior commit's K = 11.48 divisor from session 8 of
RW2_TCA_investigation.md was fit against a raw-vs-JPEG edge-position
measurement that filtered edges to those with all three integer R/G/B
peaks agreeing within +/- 1 pixel. That filter by construction excludes
every multi-pixel-shift edge, i.e. the exact edges the CA correction is
meant to fix. The surviving sub-pixel-edge subset gave "applied
correction" values of only 0.02-0.14 px, and fitting K to that subset
produced a correction 10-15x too small for real strong-CA edges.

Field test on P1366392.RW2 (G9 + Leica DG 12-60 @ 14mm, outside the
training corpus) confirms the bug: the darktable render with K = 11.48
leaves obvious multi-pixel magenta fringing on every branch silhouette
against sky, while the camera JPEG on the same frame has clean edges.
|R-G| and |B-G| channel differences show bright edge halos throughout
the darktable render and essentially none in the JPEG.

Adobe DNG Converter's WarpRectilinear opcode is a workable ground
truth: on P1366477 (PL 12-60 @ 12mm, training corpus) the opcode
prescribes +0.77 px R shift at r=0.5 and +1.81 px B shift at the
corner, and session 5's C_R evaluation reproduces those values within
a few percent without any K divide. Setting K = 1 makes the shipped
patch apply the DNG-derived magnitude directly. Re-rendering P1366392
at K = 1 clears the magenta fringing to near-JPEG parity.

Rename _pana_K_JPEG to _pana_K since the name no longer reflects the
role, and update the comment block to record why. Add session 13 to
RW2_TCA_investigation.md documenting the finding, the audit trail
and the residual (undecoded B-plane higher-order terms) that this
does not close.
Session 13's commit refuted K = 11.48 and the framing that went with
it: no unexplained per-body K spread, no missing firmware constant to
recover K from, and no ambiguity between a real ~10x factor and a
measurement artifact - the factor was a measurement artifact. Three
Optional TODO items still framed themselves around the refuted
premises:

- TODO 1 (G9 firmware RE) claimed extracting the firmware would
  "resolve the origin of the K factor" and "whether the per-body K
  spread is algorithmic or a measurement artifact". Session 13
  answered both. Drop those bullets; keep the item, refocused on the
  still-open B-plane higher-order coefficients (k_r2, k_r3) and the
  polynomial-vs-spline shape question.

- TODO 4 (Third-body K validation) was framed as "if a third body's
  K lands close to 11.48, ship as-is with more confidence". K = 1
  is now what we ship; the third-body test is still worth doing, but
  as validation that C_R generalises at unit magnitude, not as a
  measurement of a per-body K.

- TODO 5 (SPD file format) mentioned "the ~10x factor and the
  per-body K spread might live there". Neither exists anymore.
  Downgrade the item to "could explain residual per-lens variance at
  K = 1", flagging its lower priority since Adobe DNG (which C_R
  matches) does not appear to consult such an overlay.

Historical session narratives (5-12) are left as they were - they are
the audit trail session 13 refutes, and rewriting them would break the
document's own logic.
Removed session 7's ±1-integer-peak filter, redid the raw-CA measurement
via 128x128 tile phase-correlation across all 18 corpus files, and
attempted to refit C_R/C_B (4 rows each) against the resulting direct
raw-side shifts.

Findings:
- The user-reported residual on P1366392 (Leica DG 12-60 @ 14mm, G9) is
  a shape problem, not a magnitude one. With only C_B_lo (2 rows,
  session 5), the shipped B polynomial can only produce ~0.07 px of
  correction at r = 0.95, where the raw actually carries +2.47 px of
  B shift. Session 13's K = 1 fix already handled magnitude for R; the
  missing piece is the B channel's k_r2 and k_r3.
- Refitting C_R and C_B against direct raw measurements does not
  generalise. LOGO R^2 flips from 0.97-0.99 (Adobe DNG target,
  session 5) to negative on most coefficients. The direct-measurement
  targets are noisier than Adobe DNG's smoothed opcode values, and
  the ground truth still has 3-4x cross-body variance on B's higher
  orders on the same lens and focal length.
- A hybrid using corpus-median Adobe D_B.k_r2/k_r3 as constants does
  essentially nothing, because the median is near zero (Adobe's values
  span negative to positive by near-equal amounts).

Verdict: the 18-file / 9-lens-focal / 2-body corpus does not support
recovering B's k_r2 and k_r3. Either a third body with paired JPEGs
(option 3 in the Optional TODOs, samples being requested at the time
of this session) or firmware RE (session 13 declared not tractable)
would break through. No code change; C_R, C_B_lo and K = 1 stay
shipped as-is. cacorrectrgb remains the practical user-side workaround
for the residual.
@jsmucr

jsmucr commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

@TurboGit Might be, but currently I only take it as my private investigation open to feedback. :-) In a proper issue I'd be expecting to be told to switch back to the lensfun preset.

However... I may need some more RW2+JPEG pairs for this, so perhaps a forum post would be adequate.

First pass on a third-body corpus using pre-production Panasonic
DC-S5M2 samples from photographyblog. Four paired RW2+JPEG files
covering the LUMIX S 85 f/1.8, S 20-60 and S 14-28 lenses across
14 mm to 85 mm focal length.

Positive:

- 0x011b tag is present on all 4 files with count = 64, and all four
  Rigo 2011 checksums pass. The checksum algorithm generalises to a
  new body family unchanged.
- Radii ratios N2/N1 = 6/7, N3/N1 = 4/7, N4/N1 = 2/7 match session 6's
  DC-S5 full-frame pattern verbatim. The 4-zone structural model
  session 6 verified on the older DC-S5 applies to the newer DC-S5M2
  without change.
- Physical CA magnitudes measured by tile phase-correlation are on the
  same order as MFT bodies. B shift at r = 0.85 sits at ~1 px across
  all three lens/focal groups, matching the P1366392 residual.
- rawpy + our measurement pipeline handle the 6000x4000 full-frame
  sensor without code changes (halfdiag is per-file).

Negative:

- Combining the 4 S5M2 files with the 18-file MFT corpus and refitting
  the six-word regression degrades B decode further at r = 0.85 (LOGO
  R^2 goes from +0.52 on MFT-only to -2.70 on the 22-file combo).
  Four S5M2 files across three lens-focal groups is under the
  parametrisation boundary for a 4-coefficient B polynomial.
- Sweep of predictor sets (6, 8, 9, 27, 32 words) on the same 22-file
  corpus at r = 0.85 confirms: more predictors makes things worse.
  Session 5's [8, 10, 12, 20, 23, 27] remains the best six-word set.
  The bottleneck is data size, not features.

No code change. C_R, C_B_lo and K = 1 continue shipping from session
13. The batch confirms option 3 is the right path but the sample size
needs to be ~4x larger before the B higher-order decode has any
chance.
RW2_TCA_investigation.md session 16 grows the corpus from the 18 MFT
files of session 5 to 132 files across 14 bodies (5 full-frame:
S5M2, S1M2, S1M2E, S1RM2, S9) and 50 unique lens/focal groups. The
new C_R (24 doubles) and C_B (24 doubles, up from C_B_lo's 12) come
from a direct raw-CA centroid fit on the combined corpus, not from
Adobe DNG WarpRectilinear as session 5 did. Adobe DNG remains a
useful reference for R; the B plane is where the DNG-based fit under-
corrected, and where the raw-CA fit gains the most.

With ~50 lens/focal groups constraining the polynomial, B's k_r2 and
k_r3 -- which session 5 could not decode from six words on 18 files
-- now decode cleanly under LOGO. B is shipped as four rows now
instead of two; the Panasonic branch of _init_coeffs_md_v2 evaluates
d_b = db_k[0] + db_k[1] r^2 + db_k[2] r^4 + db_k[3] r^6. K stays at
1.0.

Function-space LOGO R^2 on the 132-file corpus (r = 0.30/0.50/0.70/
0.85):

  R  +0.72 / +0.70 / +0.57 / +0.14
  B  +0.59 / +0.65 / +0.52 / +0.39

Corpus-wide RMS residual at r = 0.85 drops from 0.79/1.20 px (shipped)
to 0.66/0.52 px (new) for R/B -- better than 2x on B at the corner.

Held-out on P1366392 (the user's reported strong-CA frame, PL 12-60
@ 14mm), the B residual at r = 0.85 drops from +1.31 px shipped to
+0.70 px new, roughly 45% of the gap closed. On the rendered corner
crop the mean |B-G| drops from 0.041 to 0.035 and the mean |R-G|
drops from 0.054 to 0.038.

No RELEASE_NOTES.md entry: the prior CA restoration entry for this
cycle covers the feature, and this is a numeric refinement.
This reverts commit 3158eff (and its follow-up 2e04bf7).

The refit's function-space LOGO R^2 at r = 0.85 (R = +0.14,
B = +0.39) sounded positive but only 39% of the B corner variance is
explained by the six-word predictor, and R at the corner is worse
still. On the training corpus with strong CA that leaves the mean
residual small - which the RMS table in session 16 flattered - but
on files with genuinely weak CA the 4-row B polynomial extrapolates
past LOGO's r-range with wrong-sign coefficients, so the correction
introduces fringing where the shipped 2-row fit had left the pixel
alone.

Regression evidence: on P1366399 (Leica DG 12-60 @ 24 mm f/5.6) B at
r = 0.95 is measured at +0.83 px; shipped predicted +0.03 (no visible
action, correct); refit predicts -0.18 - the wrong sign, and applied
as a correction it pushes B further from the measured position by
1.01 px. mean absolute R-G on the user-reported crop rises from 3558
to 4419 (+24 percent) when the refit correction is applied.

Corpus-wide: 84 B and 88 R fit_ok files have some r in {0.70, 0.85,
0.95} where the refit is >= 0.15 px worse than shipped or crosses
zero with predicted magnitude > 0.3 px. Full-frame bodies (only 17
LOGO groups across 44 files) are particularly affected.

Fixing this properly needs a shrinkage prior (ridge toward the 2-row
shipped fit or toward zero) or a monotonicity constraint on B(r),
not simply more data. The K = 1 magnitude fix from 6c5bf9e stays.
Two developer hypotheses tested, both settled. The six predictor words
are continuous coefficients, not per-copy identifiers: they move
monotonically with focal length on every zoom with a focal sweep. But
about 30 of 32 words change when only the body changes, and the
predictors still move 100-500 counts between frames with lens, focal
length and aperture fixed, so the payload responds to an input the fit
cannot see.

Panasonic's scheme is not shared with anyone. Olympus ca[6] is one
whole-image polynomial per channel against 0x011b's four zones, and no
other project decodes the tag; Homeister's 2018 forum post is still the
only public RE. This closes the "Olympus ca[6] correspondence" open
question.

Two follow-ups ran to a conclusion, both negative: the inner checksums
at words [2, 7, 13] resist a Rigo-family search over ~6.3e10 tuples per
word, and normalizing the predictors by the zone radii confirms the
words are body-scaled yet leaves the fit on session 18's exact ceiling.

Names the identifiability check as the one cheap test still open.
Two tests, and together they read differently from either alone.

Bootstrapping the tile set puts per-file sampling noise at 0.04 px at
r = 0.85, yet files whose six predictor words are bit-identical disagree
by 0.24 px RMS on measured B shift, six times that. Read alone this caps
any function of the six words and leaves the refits headroom.

But a demosaic-free estimator built on the raw Bayer sub-lattices
disagrees with the AAHD pipeline by about 1 px with 29% sign flips at
r = 0.85, including a sign flip on both regression targets, with no
scale or offset relation that would let one be recovered from the other.
Separately, four corpus files have no AAHD tiles beyond r = 0.7, so
their r >= 0.85 values are extrapolation reaching +14.6 px, and every
comparison in sessions 17, 18 and 20 was scored at those radii.

So the near-duplicate floor is most economically explained as
scene-dependent estimator bias rather than a missing metadata input, and
sessions 14-20 optimised against a target whose per-file bias is
comparable to the signal. That accounts for the whole shape of the
ceiling, including session 18's visible regression on P1366392 despite
an analytically correct-sign prediction.

The shipped configuration stands on session 18's rendered-pixel check
against SOOC crops, which does not depend on the AAHD pipeline, and that
is the currency any future attempt should be scored in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants