Skip to content

[fix] interpolate inside the bracket in the DNG solver (#325) - #327

Merged
antond-weta merged 1 commit into
AcademySoftwareFoundation:mainfrom
thc1006:fix/dng-solver-bracket
Sep 13, 2026
Merged

antond-weta merged 1 commit into
AcademySoftwareFoundation:mainfrom
thc1006:fix/dng-solver-bracket

Conversation

@thc1006

@thc1006 thc1006 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Description

This fixes the linear interpolation step in find_camera_to_XYZ_matrix and two smaller problems in the same loop. The numbers and the reproduction are in #325.

The interpolation between the two samples that bracket the root used current_mired + ... where it needs current_mired - ..., so the estimate landed outside the bracket. On the test fixture the solver reported 5317K, where the error is 8.4 mired; the interpolated root is at 5566K, where it is 0.0001 mired.

The loop now keeps track of whether a previous valid sample exists. Before, a sample skipped because its matrix was not invertible left last_error at 0, which made the next sample look like a sign change and produced twice its mired value. The sweep also ends with a sample at high_mired instead of stopping just below it, so a neutral under the warmer calibration illuminant is matched exactly. Identical illuminants still fall back to the first matrix, as before.

Single colour matrix

A DNG with only ColorMatrix1 arrives with calibration[1].illuminant == 0 and an all-zero second matrix. It came out right on main by accident: the zero matrix at the first sample is skipped, the bogus sign change then returns twice the next sample's mired, that is out of range, and the weight clamps back to ColorMatrix1. With the sweep fixed it would interpolate towards the zero matrix and return the matrix scaled by 33x on the fixture (see the comment on #325). So the sweep now only runs when the second illuminant is known and its matrix is present and not all zeros, which is the condition the DNG SDK uses (HasColorMatrix2() plus both temperatures known); otherwise the first matrix is used as is. That also covers a Metadata built by hand with one calibration, where the second matrix is empty and the old code read past its end, and the case of CalibrationIlluminant2 present without ColorMatrix2, where main returned a matrix off by 20% and the first version of this PR by 7x. An illuminant2 of 0 (EXIF "unknown") next to a valid ColorMatrix2 was interpolated as 5500K before and is ignored now, as in the SDK.

An empty range (identical illuminants, or both beyond the same clamp) takes no samples and falls back to the first matrix as before. neutral_RGB with fewer than three values is rejected like an empty one instead of being read past its end.

About the goldens

The DNG goldens in testDNGIdt.cpp, usage_example_core.cpp, python/test_core_bindings.py and python/test_image_converter.py were recorded from the old estimate (188.05 mired) and change to the values at 179.64 mired. I did not tune them by hand, they are what the fixed code produces; the residual of the solver at the new estimate is 1.5e-4 mired versus 8.4 mired before, which is the reason I think they are right to change.

The doc comment of the function said "binary search", which it never was; I changed it to describe the sweep.

Overlap with #277

Two lines in the same loop are touched by #277 too. A 3-way merge resolves it by itself (checked both orders); if it does not for some reason, whichever lands second I'll rebase.

Tests

  • testIDT_FindCameraToXYZMtx: golden updated, 5317k to 5566k.
  • testIDT_FindCameraToXYZMtx_NonInvertibleFirstSample: singular ColorMatrix1. The first sample is skipped and the result is the closest valid sample (6295k) instead of twice its mired (3147k).
  • testIDT_FindCameraToXYZMtx_MatchAtHighMired: neutral built for 2856K, the warmer calibration illuminant. Comes back as the first calibration matrix. Only the matrix is checked: the logged temperature is 2856.00002K, too close to the int() boundary to pin the string.
  • testIDT_FindCameraToXYZMtx_SingleCalibration: second illuminant 0 with an all-zero and with an empty second matrix, and a known second illuminant with an all-zero matrix; the result is the inverse of ColorMatrix1 and nothing is interpolated. On main the three variants report 2676k, 2676k and 3147k.
  • testIDT_FindCameraToXYZMtx_IdenticalIlluminants: both calibrations for 2856K; the first matrix comes back with no message, as before.
  • testIDT_FindCameraToXYZMtx_KnownIlluminants: neutrals built from the interpolated matrix at 3000K, 3700.5K, 4500K, 5500K and 6200K; the solver has to return that matrix within 1e-5. All five fail on the previous code (off by several mired) and pass now within 3e-6.

I checked that every new or updated assertion fails when the old rawtoaces_core.cpp is swapped back in.

I also ran the sweep on 20k generated fixtures (fixture's calibration matrices, random illuminant pairs, target CCT with a known answer). For targets inside the calibration range the estimate was off by 4.59 mired median / 8.96 p90 before and 0.0007 / 0.0145 after; 12294 of 14082 fixtures were off by more than 1 mired before, 18 after (those have more than one root in the range, the solver picks the first).

Full ctest 17/17, install and config_tests on Ubuntu 24.04 with gcc 13.3, also with C++20 and with Eigen off; ASan+UBSan with clang 18 on the tests, tests/run_valid.sh and 60k fuzzed metadata sets (random and degenerate illuminants, matrices and neutrals) clean; CodeChecker (clangsa + clang-tidy) and clang-format 16 clean.

Checklist:

  • I have read the contribution guidelines.
  • I have read the Policy on AI Coding Assistants and if I used AI coding assistants, I have an Assisted-by: TOOL / MODEL line in the pull request description above.
  • I have updated the documentation, if applicable. (No user-facing documentation describes the solver; the doc comment is updated.)
  • I have ensured that the change is tested somewhere in the testsuite (adding new test cases if necessary).
  • My code follows the prevailing code style of this project. If I haven't already run clang-format before submitting, I definitely will look at the CI test that runs clang-format and fix anything that it highlights as being nonconforming.

Assisted-by: Claude Code / Claude Opus 5
(Used to write the probe that replicates the sweep, to run the fixture sweep above and the CI steps locally, and to draft the tests; I reviewed the change and the goldens myself.)

@codecov-commenter

codecov-commenter commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.01%. Comparing base (7ad3dc3) to head (bbf03fe).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #327      +/-   ##
==========================================
+ Coverage   91.97%   92.01%   +0.03%     
==========================================
  Files          17       17              
  Lines        3814     3830      +16     
  Branches      562      567       +5     
==========================================
+ Hits         3508     3524      +16     
  Misses        306      306              
Files with missing lines Coverage Δ
src/rawtoaces_core/rawtoaces_core.cpp 98.56% <100.00%> (+0.03%) ⬆️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7ad3dc3...bbf03fe. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…reFoundation#325)

find_camera_to_XYZ_matrix sweeps the mired range between the two
calibration illuminants and, once the error changes sign, interpolates
between the last two samples. The interpolation added the correction
where it should subtract it, so the estimate landed outside the
bracket, as far past the current sample as the root is before it. On
the test fixture the reported illuminant was 5317K, where the error is
8.4 mired, instead of 5566K, where it is 0.0001 mired.

Two smaller problems in the same loop: a sample skipped because its
matrix was not invertible left last_error at zero, so the next sample
always looked like a sign change and produced twice its mired value;
and the loop stopped before high_mired, so a neutral shot under the
warmer calibration illuminant could not be matched exactly.

Fix the sign, track whether a previous valid sample exists, and sample
high_mired as the last step.

A DNG with a single colour matrix arrives here with illuminant 0 and an
all-zero second matrix. It only produced the right result because the
zero matrix was skipped and the bogus sign change then pushed the
estimate out of range, where the weight clamps back to the first
matrix. With the sweep fixed it would interpolate towards the zero
matrix instead, so, like the DNG SDK, the sweep now only runs when the
second illuminant is known and its matrix is present; otherwise the
first matrix is used as is.

The DNG goldens recorded from the old estimate are updated, and the new
tests fail on the previous code.

Resolves AcademySoftwareFoundation#325.

Assisted-by: Claude Code / Claude Opus 5
Claude-Session: https://claude.ai/code/session_01UUqNrkHJbRcyzA8NxvSsoQ
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@antond-weta
antond-weta force-pushed the fix/dng-solver-bracket branch from 48dedda to bbf03fe Compare September 13, 2026 20:41

@antond-weta antond-weta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@antond-weta
antond-weta merged commit 1f53c01 into AcademySoftwareFoundation:main Sep 13, 2026
26 checks passed
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.

3 participants