Skip to content

calibrate: find the chart beside a shadow, and hold the matrix to the camera's noise - #44

Merged
widgetii merged 3 commits into
mainfrom
calibrate-noise-budget
Oct 2, 2026
Merged

widgetii merged 3 commits into
mainfrom
calibrate-noise-budget

Conversation

@widgetii

@widgetii widgetii commented Oct 2, 2026

Copy link
Copy Markdown
Member

Two Calibrate defects, both found calibrating a lab gk7605v100 + SC2239 against a ColorChecker.

The chart was not found in plain view

The detector calls a pixel flat when its local range is under three times the frame's noise, and it takes the noise from the frame's quietest tenth. Shot noise grows with the light, so the quietest tenth is the darkest tenth. On a frame with real shadows the threshold landed below the noise of the chart's own bright patches, and they broke into specks.

Under window light: threshold 10.9 counts, 9 of 24 patches survived (18 are needed), and the chart was reported missing. The same chart under a lamp was found (threshold 18.8), so whether Calibrate worked depended on the room.

The detector's downscaled luma is now black-subtracted and square-rooted (Anscombe's transform), so noise is one size at every brightness.

frame before after
window light no chart 23 cells
lamp, earlier 19 cells 22 cells
lamp 22 cells 22 cells

The corners agree within 6 px across all three frames. A smoke test draws a chart with shot noise beside a shadow covering a third of the frame. The engine before this finds nothing (only those two checks fail); after, it finds all 24 cells, 0.0 px from the drawn corners. The real chart-on-wood frame still passes.

The matrix made the video noisy

A chart fit sees 24 averaged patches, so it never sees noise, and it is free to buy accuracy by amplifying the sensor's noise. On the SC2239 it did: mean ΔE2000 went from 8.9 to 5.7 with a green row amplifying noise 3.73× against the 1.69× of the matrix the camera ran. After saving it, the camera's video carried 2.2–2.6× the chroma noise on flat surfaces (temporal std over 40 RTSP frames, back to back with the matrix it replaced).

  • noiseGain(): per output channel, the length of each matrix row with the white balance gains folded in.
  • solveFromPatches(…, { noiseBudget }): holds the fit under the budget with a penalty that is steepened until it holds. What the chart alone asked for comes back as noise.free. A budget the free fit already meets changes nothing.
  • The editor budgets against the camera's own matrix at the measured light, read from the profile it already runs, times NOISE_HEADROOM = 1.25. The result says how much noisier than today the picture will be and what an exact match would have cost; with no profile to compare against, it says the noise was not checked.

Why 1.25, measured on the same camera, light and chart:

matrix video chroma noise vs stock chart colour error (a*b*, JPEG)
stock 1× 14.0
free fit 2.2–2.6× 10.8
held at 1.0 ≈1× 12.9
held at 1.25 1.0–1.3× 10.4

The shipped path was also run against the camera under window light: chart detected (23 cells), held at 2.27 against a 2.26 budget, ΔE2000 5.36 against 4.82 free. Saved to the camera, the video's chroma noise stayed within stock's range on the dark floor, wardrobe and chair, and rose from 0.5 to 0.7 on the plain wall.

Checks

node --check on every module, tools/build.sh with dist/ in sync, tools/smoke.mjs and tools/ui-check.mjs all pass, run in node 20 + clang/lld + chromium. Each new check was watched failing without its fix.

AI Dev added 2 commits October 2, 2026 09:04
…d luma

The detector calls a pixel flat when its 3x3 range is under three times the
frame's noise, and takes the noise from the quietest tenth of the frame. A
sensor's noise is mostly shot noise, which grows with the light, so the
quietest tenth is the darkest tenth -- and on a frame with real shadows the
threshold landed under the noise of the chart's own bright patches. They
shattered into specks and the chart was reported missing in plain view.

Measured on a lab gk7605v100 + SC2239, chart lit by window light: threshold
10.9 counts, 9 of 24 patches survived as candidates (18 are needed), no
chart. The same chart under a lamp, with brighter shadows, came out at 18.8
and was found -- so whether Calibrate worked depended on the room.

The downscaled luma is now black-subtracted and square-rooted (Anscombe's
transform less its constant scale, which a relative threshold does not
need), so noise is one size at every brightness. On the three frames taken
of that chart the threshold lands at 1.6 to 1.7 and detection goes:

  window light   no chart  -> 23 cells
  lamp, earlier  19 cells  -> 22 cells
  lamp           22 cells  -> 22 cells

with the corners within 6 px of each other across all of them. The ramp
test that picks the chart's orientation only asks which way the greys fall,
and a root keeps that order.

The smoke test draws a chart with shot noise beside a shadow covering a
third of the frame. The engine before this finds nothing on it (only those
two checks fail); after, all 24 cells, 0.0 px from the drawn corners.
make-chart.mjs gains `shot` (variance per count) and `dark` for it.
A chart fit sees 24 averaged patches, so their noise is gone before the
solver looks, and it is free to buy accuracy by amplifying the sensor's
noise. On a lab gk7605v100 + SC2239 under a 2330 K lamp it did: mean
ΔE2000 went from 8.9 to 5.7 with a green row amplifying noise 3.73 against
the 1.69 of the matrix the camera ran, and after saving it the camera's
video carried 2.2 to 2.6 times the chroma noise on flat surfaces (temporal
std over 40 RTSP frames, back to back with the matrix it replaced). The
picture's colour got better and the video got visibly worse.

noiseGain() is that amplification per output channel: the length of each
matrix row with the white balance gains folded in. solveFromPatches takes
an optional noiseBudget and holds the fit under it with a penalty that is
steepened until it holds (a fixed weight left rows a few per cent over).
What the chart alone asked for comes back as noise.free, so the trade can
be shown. A budget the free fit already meets changes nothing.

The editor budgets against the camera's own matrix at the measured light,
read from the profile it already runs, times NOISE_HEADROOM = 1.25. That
figure is measured on the same camera, light and chart: held at 1.25 the
video's chroma noise rose 0 to 30 per cent and the chart's colour error
(a*b*, on the JPEG) was 10.4 against 10.8 unheld and 14.0 before; held at
1.0 it was 12.9. The result now says how much noisier than today the
picture will be, and what an exact chart match would have cost -- or, with
no profile to compare against, that the noise was not checked.

The smoke test carries the SC2239's 24 measured patches and the 2525 K
matrix that camera ran: free, 2.19 times noisier; held, within 0.13% of the
budget at 7.18 ΔE2000; rows still sum to one. Both new UI checks fail
without the editor change.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Detect shadow-adjacent charts and limit calibration noise

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Stabilize chart detection across shadows and bright patches by accounting for shot noise.
• Limit fitted matrix noise against the camera’s current profile while reporting the colour
 trade-off.
• Add synthetic, measured-patch, and UI checks for both fixes.
Diagram

graph TD
  Frame["Raw frame"] --> Detector["Chart detector"] --> Samples["Patch samples"] --> Solver["Budgeted fit"] --> Report["Noise report"] --> Editor["Calibration editor"]
  Profile["Camera profile"] --> Budget["Noise budget"] --> Solver
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use an explicitly constrained optimizer
  • ➕ Could guarantee a per-channel limit or report infeasibility.
  • ➖ Adds numerical complexity and changes the existing solver more substantially.

Recommendation: Keep the escalating-penalty approach for this targeted fix: it reuses the established fit and the tests cover a measured camera case. If noiseBudget is intended as a strict API guarantee, add a final limit check and failure policy; the current loop can return a result without proving every budget was met.

Files changed (9) +396 / -18

Bug fix (6) +299 / -16
calibrate.jsShip noise-aware calibration solver +78/-2

Ship noise-aware calibration solver

• Built distribution mirrors the new per-channel noise-gain calculation, optional fit budget, and free-fit comparison.

dist/calibrate.js

editor.jsShip camera-relative noise budgeting and messaging +60/-5

Ship camera-relative noise budgeting and messaging

• Built editor derives a budget from the camera profile and displays the fitted noise trade-off or an unchecked warning.

dist/editor.js

engine.wasmRebuild chart detector binary +0/-0

Rebuild chart detector binary

• WASM artifact is rebuilt to include the changed C chart-detection path.

dist/engine.wasm

calibrate.jsConstrain matrix fitting by noise gain +78/-2

Constrain matrix fitting by noise gain

• Adds white-balance-aware, per-output noise gain and a 1.25× headroom constant. An optional budget penalizes excess gain while preserving the unconstrained fit and reporting the resulting accuracy.

src/calibrate.js

editor.jsBudget calibration against the camera’s matrix +60/-5

Budget calibration against the camera’s matrix

• Reads the current profile matrix at the estimated light to set per-channel limits. The result states its noise relative to today’s picture, or warns when no comparison was possible.

src/editor.js

engine.cStabilize chart flatness across brightness +23/-2

Stabilize chart flatness across brightness

• Black-subtracts downscaled green-channel luma and applies a square-root transform before flatness detection. This prevents dark-region noise from setting a threshold too low for bright chart patches.

src/engine.c

Tests (3) +97 / -2
ui-check.htmlCheck calibration noise disclosures +13/-0

Check calibration noise disclosures

• Asserts that a readable camera baseline yields a relative-noise message and that a missing baseline is explicitly reported as unchecked.

tests/ui-check.html

make-chart.mjsGenerate shadowed charts with shot noise +17/-2

Generate shadowed charts with shot noise

• Adds optional brightness-dependent noise and a dark region to synthetic chart frames, enabling the detector regression case.

tools/make-chart.mjs

smoke.mjsExercise shadow detection and bounded fitting +67/-0

Exercise shadow detection and bounded fitting

• Checks detection and corner accuracy beside a shadow. Measured camera patches also verify the noise budget, accuracy trade-off, free-fit reporting, and unchanged results under a slack budget.

tools/smoke.mjs

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Camera noise comparisons use the wrong balance ✓ Resolved
Description
solveCalibration passes the chart-derived free.neutral to noiseGain for the camera’s existing
matrix, rather than using the white balance in the camera’s profile. When the existing
auto-white-balance curve differs from the chart solution, both the budget and the reported noise
ratio compare against a picture the camera is not currently producing.
Code

src/editor.js[R2872-2874]

+					const budget = noiseGain(at, free.neutral).map((v) => v * NOISE_HEADROOM);
+					const held = solveFromPatches(patches, { ...opts, noiseBudget: budget });
+					held.noise.camera = noiseGain(at, free.neutral);
Evidence
noiseGain folds reciprocal neutral values into each matrix row. The chart solver derives
free.neutral from measured patches, while the baseline profile stores AWB separately from its CCM
and provides a temperature-dependent gain calculation; interpolating the CCM does not incorporate
that AWB.

src/editor.js[2868-2874]
src/calibrate.js[488-492]
src/calibrate.js[553-568]
src/iqprofile.js[89-120]
src/iqprofile.js[168-185]
src/editor.js[3376-3388]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The baseline matrix’s noise is calculated using the new chart-derived white balance, so the budget and displayed comparison do not represent the camera’s current picture when its AWB differs.
## Fix Focus Areas
- src/editor.js[2868-2874]
- src/iqprofile.js[168-185]
## Recommended Fix
Read the baseline profile’s AWB data, derive its gains at the measured light, and use the corresponding neutral when calculating noise for the existing camera matrix. Continue using the chart-derived neutral for the proposed matrix.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. A held matrix can exceed its noise budget ✓ Resolved
Description
solveFromPatches stops increasing its penalty at a fixed limit and returns ccmFrom(q) without
checking whether the final gains meet noiseBudget. A positive but infeasible budget, or a fit that
does not converge to the constraint, can therefore be reported as held and passed on for application
to the camera.
Code

src/calibrate.js[R631-635]

+		for (let mu = 30; free.some((v, r) => v > budget[r]) && mu <= 3e5; mu *= 10) {
+			const held = (x) => residual(x).concat(noiseGain(ccmFrom(x), neutral)
+				.map((v, r) => mu * Math.max(0, v - budget[r])));
+			q = levenberg(held, q);
+			if (noiseGain(ccmFrom(q), neutral).every((v, r) => v <= budget[r] * 1.005)) break;
Evidence
ccmFrom constrains every row to sum to one, giving each row a nonzero minimum gain for finite
positive neutrals. The loop has a maximum penalty but no failure check after it, and the editor
applies the returned matrix without another budget check.

src/calibrate.js[348-355]
src/calibrate.js[488-492]
src/calibrate.js[627-649]
src/editor.js[2871-2875]
src/editor.js[3143-3152]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The penalty loop can end with gains above the requested budget, but the solver still returns and labels that matrix as held.
## Fix Focus Areas
- src/calibrate.js[631-649]
- src/editor.js[2871-2875]
## Recommended Fix
Check every final gain against its budget after optimization. If the constraint cannot be met, return an explicit failure rather than a held matrix; distinguish that failure from an unreadable baseline in the editor.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Budget failures silently remove the limit ✓ Resolved
Description
solveCalibration catches errors from the budgeted solveFromPatches call together with
baseline-read errors, labels every one no-baseline, and returns the free fit. If budget validation
or the constrained solve fails after a baseline was read, the operator receives an unconstrained
calibration instead of the error that prevented the noise check.
Code

src/editor.js[R2877-2879]

+			} catch {
+				why = 'no-baseline';
+			}
Evidence
The catch surrounds baseline parsing, CCM interpolation, budget calculation, and the second solver
call. That solver explicitly throws for an invalid budget, yet the catch converts the exception into
no-baseline and returns the previously computed free result.

src/editor.js[2852-2853]
src/editor.js[2867-2882]
src/calibrate.js[626-630]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A catch intended for an unavailable baseline also swallows errors from calculating and solving the noise budget, then offers the unconstrained fit.
## Fix Focus Areas
- src/editor.js[2867-2882]
- src/calibrate.js[626-649]
## Recommended Fix
Limit the fallback catch to baseline retrieval and parsing. Let budget-validation and constrained-solver failures surface as calibration errors, with their original error details, rather than returning the free fit.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Noise allowance creeps up with each recalibration 🐞 Bug ≡ Correctness
Description
measureChart builds the budget from calibrate.baseline() (the profile the camera runs today)
times NOISE_HEADROOM, but persist(ini) via Build the camera profile writes this tool's held
matrices back into that same profile. Every later calibration is therefore capped at 1.25× an
already-raised matrix (≈1.95× stock after three rounds), while the note still reports staying within
1.25× of 'today'.
Code

src/editor.js[R2868-2872]

+				const own = readColour(parseIni(await calibrate.baseline())).ccm;
+				if (!own) why = 'no-baseline';
+				else {
+					const at = vendorCcmAt(own, free.light.cct);
+					const budget = noiseGain(at, free.neutral).map((v) => v * NOISE_HEADROOM);
Evidence
baseline() is documented as returning the colour sections the camera runs today and persist writes
colour sections into that profile; buildProfile merges session held ccms into tables written back;
readColour reads [static_ccm] directly and vendorCcmAt blends it, so the budget reference drifts.

src/editor.js[262-265]
src/editor.js[2863-2875]
src/editor.js[3388-3398]
src/iqprofile.js[88-115]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The noise budget is computed from the camera's current [static_ccm], which after persist contains this tool's own earlier output, so the 1.25× headroom compounds across calibrations.
## Fix Focus Areas
- src/editor.js[2863-2882]
- src/editor.js[3349-3370]
- dist/editor.js[2863-2882]
## Recommended Fix
Capture the vendor/stock CCM tables before the first persist (or store a marker/reference gain in the written fragment) and compute the budget from that fixed reference instead of the live baseline; rebuild dist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/editor.js Outdated
Comment thread src/calibrate.js
Comment thread src/editor.js
Comment thread src/editor.js Outdated
Comment on lines +2868 to +2872
const own = readColour(parseIni(await calibrate.baseline())).ccm;
if (!own) why = 'no-baseline';
else {
const at = vendorCcmAt(own, free.light.cct);
const budget = noiseGain(at, free.neutral).map((v) => v * NOISE_HEADROOM);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

4. Noise allowance creeps up with each recalibration 🐞 Bug ≡ Correctness

measureChart builds the budget from calibrate.baseline() (the profile the camera runs today)
times NOISE_HEADROOM, but persist(ini) via Build the camera profile writes this tool's held
matrices back into that same profile. Every later calibration is therefore capped at 1.25× an
already-raised matrix (≈1.95× stock after three rounds), while the note still reports staying within
1.25× of 'today'.
Agent Prompt
## Issue description
The noise budget is computed from the camera's current [static_ccm], which after persist contains this tool's own earlier output, so the 1.25× headroom compounds across calibrations.

## Fix Focus Areas
- src/editor.js[2863-2882]
- src/editor.js[3349-3370]
- dist/editor.js[2863-2882]

## Recommended Fix
Capture the vendor/stock CCM tables before the first persist (or store a marker/reference gain in the written fragment) and compute the budget from that fixed reference instead of the live baseline; rebuild dist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…t cannot hold

Review findings on the noise budget:

- The camera's matrix was scored through the chart's white balance, not
  the camera's. It is now scored through the frame's AsShotNeutral, the
  balance the camera chose for this picture -- which is what "the picture
  as it is now" means. On the lab SC2239 those differ by up to 36% in the
  blue gain under daylight, because the camera's AWB lands on the wrong
  temperature there.
- A held fit that ended over its budget was returned as held. The final
  gains are now checked, and a budget no matrix can meet is an error that
  says how far over the closest one is.
- The catch that fell back to an unheld fit also swallowed solver errors.
  It now covers only reading the camera's profile; a failed held solve
  reaches the operator as the error it is.

Not changed: the reference is the camera's matrix as it is now, so a saved
calibration becomes the reference for the next one. An absolute ceiling
was measured instead and does not exist across sensors: IMX307's shipped
tables reach 2.27x the noise of plain white balance, the SC2239 library's
own tables 5.35x. The note now says what the reference is -- "a
calibration saved earlier counts as now" -- so each step is visible.
@widgetii

widgetii commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

Review addressed in bf25995:

  1. Wrong balance: fixed. The camera's matrix is now scored through the frame's AsShotNeutral, the balance the camera chose for this picture.
  2. Over budget, labelled held: fixed. Final gains are checked, and an unmeetable budget is an error saying how far over the closest matrix is. There's a smoke test for it.
  3. Catch too wide: fixed. Only reading the camera's profile can fall back to an unheld fit; solver errors reach the operator.
  4. Creep: not changed, by measurement. A fixed reference would need a ceiling that holds across sensors, and there isn't one. IMX307's shipped tables reach 2.27× the noise of plain white balance, and the SC2239 library's own tables reach 5.35×. The reference stays "the picture as it is now", and the note now says so ("a calibration saved earlier counts as now"), so each step is visible to the operator.

@widgetii
widgetii merged commit b27ff1d into main Oct 2, 2026
1 check passed
widgetii added a commit to OpenIPC/majestic-webui that referenced this pull request Oct 2, 2026
… shadow and holds the matrix to the camera's noise (#621)

Two Calibrate fixes, both found calibrating a gk7605v100 + SC2239 against a
ColorChecker (OpenIPC/raw-editor#44):

- The chart detector missed a chart in plain view whenever the frame had
  real shadows: its flatness threshold came from the darkest tenth of the
  frame and sat under the shot noise of the chart's bright patches. Under
  window light it reported no chart; it now finds 23 cells there.
- A chart fit could buy accuracy by amplifying the sensor's noise: after
  saving one, the camera's video carried 2.2-2.6x the chroma noise. The
  matrix is now held to 1.25x the noise of the one the camera runs, read
  from the profile it already serves, and the panel says what that cost.

v0.19.3 now asks baseline() during Measure as well as Build; the WebUI's
MajesticCalibrate already provides it, so nothing else changes here. Every
dist/ file jsDelivr serves at @v0.19.3 was checked byte-identical to the
tag's dist/. npm test passes.

Co-authored-by: AI Dev <ai@openipc.org>
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.

1 participant