Skip to content

Steer the day white point from what the frames render - #19

Merged
ekstremedia merged 3 commits into
mainfrom
feature/wb-feedback
Aug 9, 2026
Merged

Steer the day white point from what the frames render#19
ekstremedia merged 3 commits into
mainfrom
feature/wb-feedback

Conversation

@ekstremedia

@ekstremedia ekstremedia commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Problem

The Spjutvika camera's daylight frames carried a green-tinged khaki cast — grey storm clouds rendered sepia. Measured on live output: neutral areas at G/R 0.93, G/B 1.05 in linear terms (a* +2.5, b* +4.7).

Diagnosis

Day WB is (correctly) manual to avoid AWB flicker, but the gains were an open-loop constant — and no constant can be right. libcamera infers a colour temperature from the manual gains and swaps its CCM as that estimate moves, so rendered colour responds super-linearly to gain changes (measured: a 16% red-gain cut moved rendered red 27%). Gains that suit one weather render another as a cast. The AWB reference shot couldn't help: its fixed 0.1 s exposure blows out white in daylight, so its learned gains were garbage.

Fix: close the loop

day_mode.wb_feedback (off by default) steers a trim on the day white point from what the frames actually render:

  • Measurement is free: the lores I420 buffer the brightness metrics already read carries the U/V chroma planes. Near-neutral pixels (grey cloud, grey water — the scene's own grey card) are selected at 160×120 and reported as two linear-domain ratios that read 1.0 when greys render grey. No disk read, no overlay contamination, ~1 ms of numpy.
  • Update is damped and proportional on the log scale: trim ×= ratio^strength (default 0.05). No CCM calibration to be wrong about; stable while strength × CCM slope ≪ 1; moves well under 1%/frame, far below the WB cross-fade's own smoothing — nothing steps.
  • Bounded: clamped to ±max_trim (default 12%) around the configured anchor. The loop absorbs weather and CCM drift; it does not fight a sunset.
  • Aurora-safe: updates gated to day frames, and the trim rides the day endpoint of the existing cross-fade only — night colour is exactly the configured value, always.
  • Restart-proof: one line of JSON in data/wb_trim.json spares the ten-minute re-learn.

Verification

  • 1341 passed, 2 skipped — including the replay golden masters (feature off by default ⇒ existing configs byte-identical).
  • New: round-trip tests for the I420 chroma statistics (buffers painted from RGB through the same BT.601), and closed-loop controller tests (direction, clamp, night/no-grey/broken-reading gates, seeding, diagnostics).
  • Live on the Spjutvika camera: with the anchor retuned to measured-neutral [2.26, 1.73], grays render within 2% of neutral; the enabled loop began trimming the residual within three frames ({"wb_trim_r": 1.0006, "wb_trim_b": 1.0018}) and holds it as weather changes.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added optional daytime white-balance feedback that gradually corrects colour casts using neutral image areas.
    • Corrections are bounded, applied only during daytime, and leave night and aurora colour unchanged.
    • White-balance adjustments persist across restarts when enabled.
    • Added diagnostic reporting for applied red and blue trim adjustments.
  • Documentation

    • Added configuration and tuning guidance for enabling and adjusting daylight white-balance feedback.

The daylight render sat khaki for months because nothing watched the
output: day gains were an open-loop constant, and no constant can be
right. libcamera infers a colour temperature from the manual gains and
swaps its colour matrix as that estimate moves, so rendered colour
responds super-linearly to a gain change -- measured live on this
camera, a 16% red-gain cut moved the rendered red by 27% -- and the
gains that suit clear sky render overcast grey as green-tinged khaki.

Close the loop instead. The lores buffer the brightness metrics already
read carries the U/V chroma planes; its near-neutral pixels -- grey
cloud, grey water, the scene's own grey card -- are selected there and
reported as two linear-domain ratios that read 1.0 when greys render
grey. The controller multiplies a trim on the day white point by each
ratio raised to a small exponent: proportional on the log scale, no CCM
calibration to be wrong about, stable while strength times the CCM's
slope stays well under one, and moving well below the cross-fade's own
smoothing, so nothing steps.

The trim is clamped to +/-max_trim around the configured gains -- the
loop absorbs weather and CCM drift, it does not fight a sunset -- and
updates are gated to day frames, because at night there is nothing
neutral to meter and an aurora is the one thing this must never
white-balance away. It rides the day endpoint of the existing
cross-fade only, so night colour is exactly the configured value,
always. A one-line state file survives restarts, sparing the ten-minute
re-learn. Off by default; existing configs and the replay golden
masters are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84feb350-83b0-4f48-aa2f-c0cdbe9916bf

📥 Commits

Reviewing files that changed from the base of the PR and between df03195 and 56b1e16.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • README.md
  • docs/EXPOSURE.md
  • tests/test_wb_trim_persistence.py
📝 Walkthrough

Walkthrough

The change adds opt-in daytime white-balance feedback. Lo-res neutral-pixel metrics drive bounded daylight trims, which persist across restarts and appear in diagnostics. Night and aurora colour remain unchanged. Tests and documentation cover configuration, metrics, control, and persistence.

Changes

Daylight white-balance feedback

Layer / File(s) Summary
Extract lo-res white-balance metrics
raspilapse/camera/capture.py, tests/test_wb_stats.py
The capture path validates I420 data, filters suitable pixels, and reports neutral-pixel coverage with linear RGB channel ratios. Tests cover casts, masking, highlights, and invalid buffers.
Apply bounded daytime trims
raspilapse/camera/exposure.py, tests/test_exposure.py
ExposureController updates bounded trims from eligible daytime metrics, applies them to daylight gains, restores seeded trims, and exposes diagnostics. Tests cover gating, clamping, night isolation, and seeded state.
Restore and persist trim state
raspilapse/daemon.py, CHANGELOG.md, README.md, docs/CONFIG-REFERENCE.yml, docs/EXPOSURE.md
The daemon restores validated trim state and saves changes atomically in data/wb_trim.json. Documentation describes configuration, persistence, and troubleshooting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ImageCapture
  participant ExposureController
  participant daemon
  ImageCapture->>ExposureController: Provide lo-res WB metrics
  ExposureController->>ExposureController: Update bounded daytime trim
  ExposureController-->>daemon: Expose wb_trim
  daemon->>daemon: Persist data/wb_trim.json
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: closed-loop adjustment of the daytime white point based on rendered frame data.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/wb-feedback

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.65217% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
raspilapse/camera/capture.py 91.89% 3 Missing ⚠️
raspilapse/daemon.py 94.87% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
raspilapse/daemon.py (1)

359-404: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add daemon-level tests for WB trim persistence. Cover disabled feedback, missing and malformed state files, clamped restoration, thresholded writes, and the os.replace payload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@raspilapse/daemon.py` around lines 359 - 404, The daemon lacks tests for WB
trim persistence. Add daemon-level tests covering _seed_wb_trim and
_persist_wb_trim for disabled feedback, missing and malformed state files,
clamped restoration, the 0.002 write threshold, and atomic os.replace payload
contents, using mocks or temporary paths without changing production behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 12-24: Replace the inaccurate per-frame correction wording with
the actual default bound of approximately -3.4% to +3.5%, accounting for the
accepted 0.5..2.0 ratios and the ratio ** 0.05 update. Apply this wording update
in CHANGELOG.md lines 12-24, README.md lines 158-161, and docs/EXPOSURE.md lines
164-180; retain any qualification that configured higher strength can produce
larger movements.

---

Nitpick comments:
In `@raspilapse/daemon.py`:
- Around line 359-404: The daemon lacks tests for WB trim persistence. Add
daemon-level tests covering _seed_wb_trim and _persist_wb_trim for disabled
feedback, missing and malformed state files, clamped restoration, the 0.002
write threshold, and atomic os.replace payload contents, using mocks or
temporary paths without changing production behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c2978a3-53c7-4996-96a8-f4771888584c

📥 Commits

Reviewing files that changed from the base of the PR and between 07bbed3 and df03195.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • README.md
  • docs/CONFIG-REFERENCE.yml
  • docs/EXPOSURE.md
  • raspilapse/camera/capture.py
  • raspilapse/camera/exposure.py
  • raspilapse/daemon.py
  • tests/test_exposure.py
  • tests/test_wb_stats.py

Comment thread CHANGELOG.md
ekstremedia and others added 2 commits August 9, 2026 14:36
Both from review. The docs claimed the trim moves "well under 1% per
frame", which is how it behaves against a real cast but not what the
code bounds: the input clamp accepts ratios up to 2:1, and 2 ** 0.05 is
a 3.5% step. Say both numbers -- the typical and the cap -- instead of
a soft phrase that reads as a guarantee.

The daemon's half of the trim's restart survival -- seeding from the
state file, writing it back only past the threshold -- ran only in
production. Cover it: restore, clamp on the way in, malformed and
half-missing files, the disabled loop neither reading nor writing, the
0.002 write threshold in both directions, and one full round trip. The
state path is patched per test so the suite can never read a running
camera's trim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's black --check rejected the persistence tests: three short calls
wrapped across lines that fit in one. Black is now installed locally,
so the next hand-formatting drift gets caught before the push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ekstremedia
ekstremedia merged commit c03dd28 into main Aug 9, 2026
8 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.

1 participant