Skip to content

Optical black intensity without clipping, SNR weighting and image averaging - #157

Closed
kriboe90 wants to merge 6 commits into
masterfrom
test_dark_level_correction
Closed

kriboe90 wants to merge 6 commits into
masterfrom
test_dark_level_correction

Conversation

@kriboe90

@kriboe90 kriboe90 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Draft for the open half of the dark level correction. The finished half was split out into #158, which should merge first. This branch still contains it, so its diff will shrink to the remainder once #158 is in master.

What belongs here

  • ob_sum_col_value and ob_noise_sum_col_value: the integrated intensity of an LED without the clipping at zero, with the black level of every color of the Bayer pattern taken individually, and the read noise of that sum from the optical black.
  • snr_full_weight and min_layer_weight: the linear solver weights every LED with min(1, SNR / snr_full_weight), and layers whose path-length-weighted mean LED weight stays below min_layer_weight become NaN instead of resting on LEDs near the noise level.
  • num_avg_images: centred moving average of the reference property over N images before the normalisation, with the reference intensities taken from the single images.
  • Still to come: removing the clipping at zero from the default channel data, so that sum_col_val and the side band columns profit from it as well and not only an opt-in column.

What moved to #158

The merge of master, the optical black as the mandatory black level source with the fallback to the metadata value, the LEDSA version in the step 3 header, and the binary data guard. None of that has an open question, and it improves every reference property rather than an opt-in one.

What was dropped

  • dark_img_indices, which subtracted a per-LED dark level taken from images with the LEDs and the ambient light switched off. ob_sum_col_val takes the same information from the optical black pixels of every image, per Bayer color and without extra exposures at the experiment, so the option was superseded by its own commit message. It spanned seven files, a solver attribute, subtract_dark_intensities and an option of config_analysis.ini.
  • read_channel_data_and_mask_from_img, which has no caller left because step 3 reads through read_channel_data_and_optical_black_from_img. Its three unit tests now cover that reader instead, the first one driving it through a fake RAW image rather than mocking the function under test.

Why this stays a draft

  • The clipping at zero is the point and is not in yet. Removing it lets the sums go negative, which changes a contract that max_col_value, the fit and the postprocessing rely on, and the nonlinear solver and the stacked analysis are not covered.
  • Two policies for "the LED has vanished" would then coexist. subtract_pedestal clamps to max(S - N * B, background_floor * S) and its own docstring says values at the limit carry no information on the extinction, yet nothing marks them. The answer here is the opposite: keep the negative value and discard it by SNR. Combined, the floor must not clamp a sum based on the optical black. Today the two cannot meet, because NOISE_COLUMNS wires the SNR weighting to ob_sum_col_val alone and that column never goes through subtract_pedestal.
  • ob_noise_sum_col_value is the read noise of the box only. Subtracting a side band pedestal adds the variance of that estimate, and the side bands hold far fewer pixels than the box, so the weighting would be optimistic exactly in dense smoke.
  • Validation. The comparison against BCL covers the Warsaw tests V008 and V009, camera 2. No RAW image is read by any unit test, and the acceptance tests have not run on this branch.

Note on the merge state

This branch conflicts with master in four files, because #155 and #156 landed after the merge commit it carries. That is left as it is on purpose: merging master again now would duplicate the resolution that #158 already contains. Once #158 is merged, master comes in here and the conflicts reduce to the parts this branch really changes.

🤖 Generated with Claude Code

kriboe90 and others added 4 commits October 26, 2026 21:41
Step 3 writes two additional columns, ob_sum_col_value and
ob_noise_sum_col_value: the integrated LED intensity with the black level
taken from the optical black margin of the sensor and without clipping at
zero, and its read noise. sum_col_val keeps a constant offset from the
metadata black level and the clipping at zero (0.3-0.7 % of I0 in the
Warsaw tests), which saturates the extinction coefficients in dense smoke.
sum_col_val and its column layout are unchanged.

New options of the linear solver (config_analysis.ini):
- reference_property = ob_sum_col_val
- snr_full_weight / min_layer_weight: weight every LED with
  min(1, SNR / snr_full_weight); layers whose path-weighted mean LED weight
  is below min_layer_weight are set to NaN
- num_avg_images: centred moving average of the reference property over N
  images before the normalisation; reference intensities from the single
  images
- dark_img_indices: subtract a per-LED dark level taken from images with
  LEDs and ambient light off (diagnostic, superseded by ob_sum_col_val)

Not covered: nonlinear solver, stacked analysis. Checked on the Warsaw
tests V008 and V009 (camera 2) against BCL. Unit tests pass, acceptance
tests not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019vfEYDVyW5dLrb84n1nqns
The side band pedestal of #145 and the optical black intensity of this
branch both extend the step 3 output and the raw reader, so all five
conflicts sit in the files #145 touched.

- Columns: the side band columns keep their place and ob_sum_col_value
  and ob_noise_sum_col_value are appended after them, so the column
  prefix of the released v0.11.0 files stays as it is. The layouts of
  -s3_fast and -s3 now have 12 and 26 names. Step 3 files written by
  this branch before the merge (9 and 23 names) can no longer be read.
- Reader: step 3 reads through read_channel_data_and_optical_black_from_img
  and takes the channel mask of the side bands from the Bayer filter
  array, so the image is still read once for both corrections. The
  reader now returns the filter array for every RAW file and not only
  for sensors with an optical black margin, because a filter array of
  None means "every pixel belongs to the channel" to the side bands.
- _channel_mask of this branch was dropped for get_bayer_channel_mask,
  which master made public with the same body.
- The NaN fixture of test_sideband_background builds the solver without
  __init__, so it now sets the attributes load_img_data gained here:
  noise_property, dark_img_indices and num_avg_images.

read_channel_data_and_mask_from_img has no caller in the package any
more, only its own unit tests, since step 3 reads through the optical
black version.

Unit tests pass, 117. Acceptance tests not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every RAW reader now takes the black level of a channel from the mean of
the optical black pixels of the same image instead of
raw.black_level_per_channel. The metadata value is a nominal one of the
camera model, while the optical black pixels are masked pixels of the
same sensor read out with the image itself, so their mean also follows
the drift of the black level with the sensor temperature. The two differ
by a fraction of a count, which the integration over the search area of
an LED turns into a constant offset of its intensity, and that offset
does not vanish when the LED dims in dense smoke.

The metadata value stays as the fallback for sensors whose optical black
margin is narrower than MIN_OPTICAL_BLACK_WIDTH, resolved in one place
for all readers (_black_level_and_noise). JPG and PNG files have no
black level and are untouched.

This changes sum_col_val, bgsub_sum_col_val, sidebg_sum_col_val and
bgsub2_sum_col_val of RAW images, so the step 3 header now records the
LEDSA version, as the headers of the analysis results already do. The
columns alone no longer say how a file was read.

ob_sum_col_val keeps its own per-color black level and remains the only
column without the clipping at zero, so the difference between it and
sum_col_val is now the clipping alone.

Unit tests pass, 127, including the new test_black_level.py for the
estimate and the fallback. Acceptance tests not run, and no RAW image is
covered by the unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_binary_data wrote all_parameters.h5 with append=True and nothing
removed the file of an earlier run, so every LED ended up in the table
once per run and the analysis saw each image as often as the step was
repeated. The binary data are derived from the step 3 result files, so
they are now rewritten from scratch and the replacement is reported.

The column names are taken from the first result file of a channel and
used for all of them, so result files of different width cannot be
loaded together. That happens when step 3 is run again for part of the
images after a LEDSA version that writes further columns was installed.
Until now pandas stopped with "Shape of passed values is (n, 12),
indices imply (n, 10)", which says nothing about the cause. The width of
every file is now compared with the one of the first file and the error
names both files, both counts and the re-run of the data extraction.

Unit tests pass, 133, with six new ones in test_file_handling.py. They
write a coordinate file, so they take the same path through
_append_coordinates as a real analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dark_img_indices subtracted a per-LED dark level taken from images with
the LEDs and the ambient light switched off. ob_sum_col_val takes the
same information from the optical black pixels of every image, per Bayer
color and without extra exposures at the experiment, so the option is
superseded, as its own commit message already said. It spanned seven
files with a solver attribute, subtract_dark_intensities and an option
of config_analysis.ini, and it reached into a test fixture that has
nothing to do with dark images.

read_channel_data_and_mask_from_img has no caller left, because step 3
reads through read_channel_data_and_optical_black_from_img, which
returns the Bayer filter array the side bands need. Its three unit tests
now cover that reader instead: the mask that keeps channel pixels of
value 0, the filter array of None that a JPG or PNG yields, and the
error for an unsupported extension. The first one drives the reader
through a fake RAW image now, so it no longer mocks away the function
under test.

Unit tests pass, 133.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriboe90 kriboe90 changed the title Take the black level of RAW images from the optical black Optical black intensity without clipping, SNR weighting and image averaging Oct 27, 2026
The CI check for text I/O without an encoding is line based, so the
encoding of a wrapped call reads as missing to it although Python sees
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ManuelOsburg

Copy link
Copy Markdown
Contributor

Hi Kristian,

thanks for merging #145 and for the follow-up in #150.

One of the points you left open there is that values at background_floor are not flagged. #157 discusses the same question as two policies for an LED that has vanished. Our fork has a marking for this, and I'd like to know where you want it before I prepare anything.

What it does. python -m ledsa -rv writes analysis/ray_validity_channel<c>.csv with one row per ray, i.e. per image and LED. The extinction coefficients are not changed. Two criteria are evaluated, both based on the side band pedestal P = sidebg_pedestal:

  • Raw reading (sum_col_val): a ray is saturated if S − P ≤ 0, or if b_raw = −ln(S/S0) falls short of the corrected optical depth b_corr = −ln((S − P)/(S0 − P0)) by more than a relative threshold, i.e. 1 − b_raw/b_corr > threshold.
  • Side band reading (sidebg_sum_col_val): a ray is invalid in three cases:
    • the floor applies, S − P ≤ floor · S;
    • a relative error U_B of the pedestal shifts b_sidebg by more than the threshold relative to b_sidebg, with delta_b = U_B · (P/S_corr + P0/S0_corr), image and reference term added;
    • there is no pedestal (NaN).

S0, P0 and S0_corr are means over the reference images. The threshold (0.1), the minimum optical depth from which the relative criteria apply (0.1) and U_B (0.2) are set values, not derived ones.

On one camera of one of our Hasenheide runs, the marking agrees with that of our own evaluation scripts on all 216,384 rays, for thresholds from 0.05 to 0.2 and U_B from 0.1 to 0.3. That confirms the implementation, not the criterion itself.

Relation to #157. It addresses two points you name there:

  • it marks the values clamped by the floor;
  • it accounts for the uncertainty of the side band estimate, which ob_noise_sum_col_value leaves out. That uncertainty is the set relative error U_B, though, not a variance estimated from the band pixels.

It is a marking after the fact and does not weight the inversion.

Options I see:

  1. A separate PR now. It would contain only the marking, adapted to background_floor, and be independent of Optical black intensity without clipping, SNR weighting and image averaging #157. If Optical black intensity without clipping, SNR weighting and image averaging #157 later weights the solver by SNR, the marking stays a diagnostic for sidebg_sum_col_val.
  2. No separate PR; fold the criterion into Optical black intensity without clipping, SNR weighting and image averaging #157. sidebg_sum_col_val would get an entry in NOISE_COLUMNS whose noise includes the uncertainty of the pedestal, and a clamped value would get weight 0. That would leave one policy instead of two.
  3. Neither: it stays in the fork.

Which would you prefer?

On the other open point, mean vs. trimmed mean in the side bands, I could compare both estimators on our Hasenheide data, if that helps.

Best,
Manuel

@kriboe90

Copy link
Copy Markdown
Contributor Author

Replaced by #169, which re-cuts this work onto current master.

This branch had drifted eleven conflicting files away from master: the finished half merged as #158, and #161 through #167 landed after the merge commit it carries. Rebuilding the remainder on top of master was cleaner than resolving that, as it was for #158.

Carried over: the unclipped ob_sum_col_value and ob_noise_sum_col_value, the SNR weighting of the linear solver, and the moving average, now called num_moving_avg_images and reconciled with the n_summarize that #165 added in the meantime.

Dropped for good: dark_img_indices, superseded by ob_sum_col_val, and read_channel_data_and_mask_from_img, which has no caller left.

The branch stays as it is, so nothing is lost.

@kriboe90 kriboe90 closed this Oct 28, 2026
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