Repository navigation
Unclipped channel data, the read noise of a sum and the SNR weighting of the linear solver - #170
Merged
Merged
Conversation
The channel data are clipped at zero after the black level subtraction, which turns the negative half of the read noise into a positive offset of the integrated intensity of every LED. The offset stays while the LED dims in smoke, so it saturates the extinction coefficients in dense smoke, where it is 0.3 to 0.7 % of I0 in the Warsaw tests. _read_img_array_and_optical_black_from_raw_file returns the same data as before plus a version without the clipping, in which every Bayer color keeps the black level of its own optical black pixels, and the read noise of every single pixel from the spread of those pixels. The file is read once for all of them, and the orientation reaches all four arrays. _read_raw_image, _clipped_data and _unclipped_data split what was one function, so the two readers share the way the data are scaled instead of repeating it. _optical_black_mean becomes _optical_black_statistics, since the noise needs the spread of the margin next to its mean. Unit tests pass, 315. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 3 writes two columns after the side band ones: ob_sum_col_value, the integrated intensity of an LED without the clipping at zero, and ob_noise_sum_col_value, the read noise of that sum. Both are NaN for JPG and PNG images and for sensors without an optical black margin. sum_col_val and the columns before them keep their place and their values, so the prefix of the result files released with v0.11.0 is unchanged. The layouts of -s3_fast and -s3 now have 12 and 26 names, which collide with none of the earlier ones. read_channel_data_and_mask_from_img becomes read_channel_data_mask_and_optical_black_from_img and returns the two further arrays, so step 3 still reads every image once. Both carry 0 outside the color channel, so the sum over a search area and the variance of that sum need the channel mask no further. NOISE_COLUMNS names the noise column of a reference property. Only ob_sum_col_val has one, which is what the weighting of the next commit needs. The RAW test of the channel mask now mocks the one pass over the RAW file instead of one reader, so every reader derived from it stays consistent in the test. Unit tests pass, 320. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With snr_full_weight the linear solver weights every LED with min(1, SNR / snr_full_weight). The standard deviation of the optical depth is about 1/SNR, so the weight is the inverse standard deviation, capped so that well measured LEDs keep the weight 1 and the meaning of lambda_reg does not change. LEDs at or below zero signal get the weight 0, and an image whose weights do not add up to one full LED yields NaN. Layers whose path length weighted mean LED weight stays below min_layer_weight become NaN as well: their coefficients would rest on LEDs near the noise level, where the linearised Beer-Lambert law is biased. The weighting needs the noise of the reference property, which only ob_sum_col_val has (NOISE_COLUMNS). snr_full_weight with any other reference property is refused with the columns that do have one, and the loader stops if the noise column is missing from the binary data. num_moving_avg_images smooths the reference property over N consecutive images with a centred moving average, and the noise becomes the one of that mean. It is the counterpart of average_images with n_summarize, which summarizes images into fewer rows and numbers them from 1 again: the moving average keeps every image with its own time and its own coefficient. Combining the two is refused, because the result would be smoothed and decimated at once. Unit tests pass, 337, with test_led_weighting.py for the weights, the discarded layers, the two refusals and the smoothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the import list of ExtinctionCoefficients collided: #168 added avg_img_ids_of_ref_imgs and read_analysis_img_ids next to the NOISE_COLUMNS this branch imports. Unit tests pass, 365. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The data were clipped at zero after the black level subtraction, which turned the negative half of the read noise into a positive offset of the integrated intensity of every LED. The offset did not vanish while the LED dimmed in smoke, so it saturated the extinction coefficients in dense smoke, where it was 0.3 to 0.7 % of I0 in the Warsaw tests. The channel data are now float32 without a lower limit, and every color of the Bayer pattern is corrected with the black level of its own optical black pixels, which resolves the TODO on the reader: the channel no longer selects the black level. sum_col_val therefore is the unclipped sum, and ob_sum_col_val, which carried it as a separate column, is gone. ob_noise_sum_col_val becomes noise_sum_col_val, the noise of sum_col_val, so the SNR weighting of the linear solver now works on the default reference property. The layouts of -s3_fast and -s3 have 11 and 25 names. What the clipping was hiding, from the audit: - The binary data cast sum_col_val and max_col_val to int. Both stay float now: the black level is a fraction of a count, which is the size of the effect, and the values may be negative. - _estimate_local_background dropped the border pixels at or below zero, because foreign Bayer colors are masked with 0. It takes the channel mask instead, since a channel pixel may now be 0 or negative and dropping those raises the median. bgsub_sum_col_val therefore changes for Bayer images, and its test of the optical depth bias turns from "biased" into "as unbiased as bgsub2". - bgsub_sum_col_val clipped pixel by pixel after subtracting the background, which put the offset straight back. It subtracts the median from the channel pixels linearly now, as the side bands do. - The linear solver clamped a relative intensity at or below zero to 1e-10, which is an optical depth of about 23 and would dominate the solve. Such an LED has no signal left, so it gets the weight 0 whether or not the SNR weighting is on, and an image with less than one fully weighted LED yields NaN. background_floor keeps the default of v0.11.0 and accepts None to switch the limit off. Whether it should be off by default is a decision of its own: with unclipped data the limit fabricates a positive sum where the signal has vanished, which the solver can then no longer tell apart from a measurement. Not touched, checked in the audit: the LED fit, which minimises a least-squares cost without assuming positive data, the nonlinear solver, the stacked analysis and the postprocessing. Unit tests pass, 362. Acceptance tests run in the CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comparison against BCL on the Warsaw tests V008 and V009 showed that the weighting is not a refinement. At the fog stages that still carry signal it changes the extinction coefficients by less than a percent (LEDSA/BCL 0.98 and 0.95 against 0.98 and 0.96 unweighted). At 1.24 and 1.64 1/m the transmission of the red LEDs is 0.1 to 0.2 % of I0 against a read noise of about 0.17 %, so 10 to 32 % of the values are at or below zero. Without the weighting only the LEDs whose noise happened to be positive survive, and k comes out 14 to 31 % too low. The guard weights.sum() < 1.0 cannot see this, because 68 to 90 % of the LEDs still carry full weight. The weighting can: a signal to noise ratio near 1 pushes the weights to about 0.1, the layer weight below min_layer_weight and the layer to NaN, which is the honest answer where the signal sits in the read noise. snr_full_weight therefore defaults to DEFAULT_SNR_FULL_WEIGHT = 10, the value the comparison used. snr_full_weight = None in the analysis configuration still switches it off, and configurations written before the option existed get the default. A default may not stop an analysis of data that cannot carry it, so the weighting now switches itself off with a note in three cases instead of raising: a reference property without a noise column, a binary file without the noise column, and a noise column that is NaN for every LED, which is what JPG and PNG images and sensors without an optical black margin give. The header of the result file reports Noise_property next to Snr_full_weight, so it states whether the weighting was used and not only whether it was asked for. An LED whose noise is unknown now keeps the full weight instead of the weight 0. A missing noise estimate is no reason to drop a measured intensity; only a known small ratio is. Without this, a RAW image without an optical black margin would have produced NaN everywhere. The nonlinear solver has no weighting and passes snr_full_weight=None explicitly, so the default does not reach it. Unit tests pass, 367, with the fallbacks and the header covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
background_floor kept the corrected sums at a fraction of the plain sum, max(S - N * B, floor * S), with 0.05 as the default of v0.11.0. It was introduced because the clipping at zero made the pedestal of a vanished LED unavoidable, so the optical depth ran away where the LED disappeared in it. With unclipped data the limit does the opposite of what is wanted. A corrected sum at or below zero is the statement that the signal of the LED has vanished, and the solver acts on it: such an LED gets the weight 0, and a layer whose remaining LEDs sit in the read noise becomes NaN. The limit turns that statement into a positive value of 5 % of the plain sum, which the solver cannot tell from a measurement and weights accordingly. On the Warsaw tests V008 and V009 this is the regime at 1.24 and 1.64 1/m, where the honest answer is NaN. The docstring of subtract_pedestal already said that values at the limit carry no information on the extinction and should be treated as invalid; nothing ever marked them. The default is therefore None, which applies no limit, and so is the default written into config.ini. A fraction is still accepted, so the behaviour of v0.11.0 is one option away, and configurations that name one keep it. The test of the limit is split: without a floor the corrected sum stays negative where the pedestal exceeds the box sum, with an explicit floor it is still clamped. Unit tests pass, 368. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #169, which is folded in here: this branch holds all of its commits. Together they finish the dark level correction whose first half merged as #158.
The problem
sum_col_valcarried an additive offset that did not vanish when an LED dimmed in smoke, so the extinction coefficients saturated in dense smoke. The problem statement of #145 names three sources; #158 removed one of them by taking the black level from the optical black pixels instead of the RAW metadata. The one left is the clipping at zero after that subtraction, which turns the negative half of the read noise into a positive offset. In the Warsaw tests it was 0.3 to 0.7 % of I0.The commits
3fff826read the RAW data without the clipping at zero as well._read_raw_image,_clipped_dataand_unclipped_datasplit what was one function, so every version of the data shares how it is scaled and the file is still read once. The orientation of #156 reaches all of them.35fc09dwrite the unclipped intensity and its noise in step 3. As two opt-in columns at that point, so the clipped and the unclipped sum could be compared in one run.552b2feweight the LEDs of the linear solver by their signal to noise ratio.snr_full_weightweights every LED withmin(1, SNR / snr_full_weight)— the inverse standard deviation of the optical depth, capped so that well measured LEDs keep the weight 1 and the meaning oflambda_regdoes not change. An image whose weights do not add up to one full LED yields NaN, and layers whose path length weighted mean LED weight stays belowmin_layer_weightbecome NaN, because their coefficients would rest on LEDs near the noise level.39b30c4stop clipping the channel data at zero. The channel data are nowfloat32without a lower limit, and every Bayer color is corrected with the black level of its own optical black pixels. That resolves the oldTODO: channel is only relevant for black level, consider individually!: the channel no longer selects a black level at all.Two further commits follow from the measurement below and have sections of their own:
f32b64fturns the SNR weighting on by default andaa4cf07takes the lower limit off the corrected sums.Because the default is now the unclipped sum, the two columns of the second commit collapse into it:
ob_sum_col_valis gone andob_noise_sum_col_valbecamenoise_sum_col_val, the noise ofsum_col_val. The SNR weighting therefore applies to the default reference property rather than an opt-in one. The layouts of-s3_fastand-s3have 11 and 25 names, which collide with none of 6, 7, 10, 21 and 24.What the clipping was hiding
Audited before anything was changed. Four places relied on non-negative data:
sum_col_valandmax_col_valtoint. The whole effect is a fraction of a count, so that cast would have discarded it. Both stay float._estimate_local_backgrounddropped every border pixel at or below zero, because foreign Bayer colors are masked with 0. It takes the channel mask instead: a channel pixel may now legitimately be 0 or negative, and dropping those raises the median.bgsub_sum_col_valtherefore changes for Bayer images.bgsub_sum_col_valclipped pixel by pixel after subtracting the background, putting the offset straight back. It subtracts the median linearly now, as the side bands do. Its own bias test flips frombias['bgsub'] < -0.02toabs(bias['bgsub']) < 0.02, which is the clearest single measurement of what this PR buys.1e-10, an optical depth of about 23 that would have dominated the solve. Such an LED has no signal left and now gets the weight 0 whether or not the SNR weighting is on.Checked and deliberately untouched: the LED fit, which minimises a least-squares cost and assumes nothing about the sign, the nonlinear solver, the stacked analysis and the postprocessing.
How num_moving_avg_images relates to n_summarize
While this work sat unmerged, #165 made the existing
average_imagesconfigurable throughn_summarize. The two are different and both are kept:average_images+n_summarizenum_moving_avg_imagesThe earlier name,
num_avg_images, was one letter from the other and indistinguishable in a config file. Combining the two is refused with a message saying which to pick, since the result would be smoothed and decimated at once.The lower limit of the corrected sums is off by default
background_floorkept the corrected sums atmax(S - N * B, floor * S)with 0.05, because the clipping made the pedestal of a vanished LED unavoidable and the optical depth ran away where the LED disappeared in it. With unclipped data the limit does the opposite of what is wanted: a corrected sum at or below zero is the statement that the signal has vanished, and the solver acts on it — weight 0 for the LED, NaN for a layer whose remaining LEDs sit in the read noise. The limit turns that statement into 5 % of the plain sum, which the solver cannot tell from a measurement. On V008 that is the regime at 1.24 and 1.64 1/m, where the honest answer is NaN.The docstring of
subtract_pedestalalready said that values at the limit "carry no information on the extinction and should be treated as invalid"; nothing ever marked them. Now something does.The default is
None, in the code and in theconfig.initemplate. A fraction is still accepted, so the behaviour of v0.11.0 is one option away, and configurations that name one keep it (aa4cf07).Tests
Unit tests pass, 362. The ones that changed, changed because the contract did:
test_masked_zero_pixels_are_ignoredbecomestest_foreign_pixels_are_excluded_by_the_mask_and_not_by_their_valueplus a new test that channel pixels at or below zero are kept, and the two "values are unchanged" tests now assert thatbgsub_sum_col_valdiffers with and without a channel mask.New:
test_black_level.pyfor the per-color black levels and scaling, the fallback to the metadata, values that stay negative, the float dtype that keeps the fraction of a count, the per-pixel read noise and the sums of an LED;test_led_weighting.pyfor the weights, the discarded layers, the two refusals and the smoothing.Measured against BCL
Run on the Warsaw tests V008 and V009, camera 2, with 39b30c4, in three variants:
n_sumissum_col_valunclipped,n_sumwthe same withsnr_full_weight = 10andmin_layer_weight = 0.5,n_bgsubthe newbgsub_sum_col_val. The remaining settings come from the existingconfig_analysis.ini. The numbers are the ratio LEDSA / BCL for the red LEDs of V008, so 1.00 is agreement.n_sumobcolumnn_sumwn_bgsubbgsubWhere the measurement is valid, the clipping was the error. At stage 2 the ratio goes from 0.87 to 0.96. The residual in the dark frames falls from 0.68 % of I0 with clipping to −0.01 % without it, which is the offset this PR is about, measured.
n_sumreproduces theob_sum_col_valof #169 exactly, as it must, since they are the same quantity.At stages 3 and 4 the red LEDs of this camera no longer measure anything. The transmission is 0.1 to 0.2 % of I0 against a read noise of about 0.17 %, so 10 to 32 % of the values are at or below zero. The step 3 data are identical to #169's; everything below comes from the solver.
Do not read the table as a regression at stages 3 and 4. The 0.97 and 0.99 of #169 were not a valid measurement either: that branch gave every LED at or below zero an optical depth of about 23, which happened to cancel the low bias of the LEDs that survived, and the scatter over strips and heights reached 1.0. The agreement was an artefact of two errors meeting. This PR replaces the fabricated 23 with weight 0, and
n_sumwthen returns NaN, which is the honest answer where the signal sits in the noise.n_sumwithout the weighting is biased low there, and the solver cannot notice. Only the LEDs whose noise happened to be positive survive the weight-0 rule, so the surviving sample is biased bright and k comes out too low: 0.86 and 0.69. The guardweights.sum() < 1.0is far too permissive to catch this, because 68 to 90 % of the LEDs still carry full weight. What catches it is the SNR weighting: with a signal-to-noise ratio near 1 the weights fall to about 0.1, the path weighted layer weight drops belowmin_layer_weight, and the layer becomes NaN.So
snr_full_weightis not a refinement once the transmission approaches the read noise — it is the only mechanism that distinguishes "no signal" from "small signal". It is therefore on by default (f32b64f), withDEFAULT_SNR_FULL_WEIGHT = 10, the value this comparison used. It costs nothing at stages 1 and 2 (0.98 and 0.95) and turns the saturation regime into NaN instead of a number that is 14 to 31 % too low.A default may not stop an analysis of data that cannot carry it, so the weighting switches itself off with a note rather than raising in three cases: a reference property without a noise column, a binary file written before the noise column existed, and a noise column that is NaN for every LED — which is what JPG and PNG images and sensors without an optical black margin give.
snr_full_weight = Nonestill switches it off by hand, and the result header now reportsNoise_propertynext toSnr_full_weight, so it says whether the weighting was used and not only whether it was asked for. The nonlinear solver has no weighting and passesNoneexplicitly.One related fix: an LED whose noise is unknown now keeps the full weight instead of weight 0. A missing noise estimate is no reason to drop a measured intensity, and without this a RAW image without an optical black margin would have produced NaN everywhere.
n_bgsubis unbiased at stages 1 and 2 (1.01, 1.02) but noticeably noisier thann_sum, and it over-subtracts by about 0.05 % of I0. In V009 it runs 0 to 4 points high and crosses above BCL from roughly 200 s at 2.17 m. That is the border ring sitting in the halo of the LED, which #145 already held against #131's estimator; the clipping used to mask it.V009 shows no regression where the effect is small. The signal there is about 15 % of I0, so the clipping barely matters:
n_sumandn_sumwlie 0.1 to 0.7 points above the stored results in the last minute before the flame ends, and at most 0.3 points from #169.One side effect to record: step 3 now writes the image times with fractions of a second, 0 to 1 s later than the existing
image_infos. The IDs and names are unchanged. It comes from the EXIF sub-second work, not from this PR, but it shifts the time axis slightly against older results and against a reference series.What is still open
Nothing in the design. Three defaults changed here — the unclipped data, the SNR weighting, the missing lower limit — and they are three parts of one decision: where the signal of an LED has vanished, say so instead of producing a number. Each is one config option away from the old behaviour.
What remains is review, and a judgement that belongs to you: the BCL comparison above covers the red LEDs of V008 and V009 on camera 2. It is the clearest data available, and it is one camera on two tests.
🤖 Generated with Claude Code