Compare 8-bit and 12-bit pictures on a dark forest scene from the Stäffelsberg tower in the 12-bit report

This commit is contained in:
pgdalmeida 2026-10-07 12:24:41 +02:00
parent 19c5873df6
commit a723646928
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
10 changed files with 949 additions and 48 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@ -0,0 +1,474 @@
{
"frame": {
"width": 5328,
"height": 3040,
"green_range_12bit": [
128,
2792
],
"share_of_frame": {
"dark (< 300 DN)": 33.2,
"mid (300-1000 DN)": 38.5,
"bright (>= 1000 DN)": 28.3
}
},
"noise_calibration": 1.089,
"noise": {
"12-bit (this frame)": [
[
156,
5.4
],
[
184,
6.2
],
[
230,
7.9
],
[
274,
10.4
],
[
336,
14.8
],
[
454,
11.2
],
[
568,
12.4
],
[
738,
13.9
],
[
1299,
16.2
],
[
1596,
18.3
],
[
2259,
20.5
],
[
2551,
21.6
]
],
"8-bit, same frame (12-bit >> 4)": [
[
156,
6.4
],
[
184,
7.8
],
[
230,
9.3
],
[
274,
11.4
],
[
336,
15.6
],
[
454,
12.3
],
[
568,
13.4
],
[
738,
14.8
],
[
1300,
16.9
],
[
1597,
18.9
],
[
2259,
21.0
],
[
2551,
22.2
]
],
"camera RGB8 (other capture)": [
[
155,
9.6
],
[
182,
10.5
],
[
228,
12.2
],
[
272,
14.6
],
[
351,
15.1
],
[
428,
15.8
],
[
602,
18.3
],
[
748,
23.1
],
[
1272,
24.2
],
[
1681,
28.3
],
[
2285,
32.0
],
[
2442,
34.5
]
],
"4 Oct frame, 0 dB": [
[
661,
17.0
],
[
893,
21.3
],
[
1189,
27.8
],
[
1666,
31.6
],
[
2177,
36.6
],
[
2587,
37.3
],
[
3024,
41.4
]
],
"2 Oct frame, \u2248 15 dB": [
[
671,
32.9
],
[
860,
37.8
],
[
1208,
46.5
],
[
1603,
55.1
],
[
2180,
66.0
],
[
2601,
72.1
],
[
3054,
77.1
]
]
},
"dark_crop": {
"x": 40,
"y": 360,
"size": 400,
"mean_green_12bit": 194.1,
"green_range_12bit": [
129,
557
],
"distinct_green_levels": {
"12-bit": 272,
"8-bit same frame": 25,
"camera RGB8": 22
}
},
"mid_crop": {
"x": 3800,
"y": 1300,
"size": 400,
"mean_green_12bit": 1606.3
},
"sizes_mb": {
"12-bit d0": 38.58,
"12-bit d0.3": 3.4,
"12-bit d0.5": 2.13,
"12-bit d1.0": 0.92,
"8-bit d0": 15.53,
"8-bit d0.3": 2.51,
"8-bit d0.5": 1.54,
"8-bit d1.0": 0.63,
"8-bit values, 12-bit file d0": 39.04,
"8-bit values, 12-bit file d0.3": 3.5,
"8-bit values, 12-bit file d0.5": 2.19,
"8-bit values, 12-bit file d1.0": 0.94,
"camera RGB8 d0": 13.54,
"camera RGB8 d0.3": 2.64,
"camera RGB8 d0.5": 1.65,
"camera RGB8 d1.0": 0.66,
"camera RGB8 today e4 d0.8": 0.92
},
"errors": [
{
"source": "12-bit",
"distance": 0,
"size_mb": 38.58,
"dark (< 300 DN)": {
"rms_px": 0.0,
"rms_avg": 0.0
},
"mid (300-1000 DN)": {
"rms_px": 0.0,
"rms_avg": 0.0
},
"bright (>= 1000 DN)": {
"rms_px": 0.0,
"rms_avg": 0.0
}
},
{
"source": "12-bit",
"distance": 0.3,
"size_mb": 3.4,
"dark (< 300 DN)": {
"rms_px": 12.28,
"rms_avg": 0.61
},
"mid (300-1000 DN)": {
"rms_px": 18.0,
"rms_avg": 0.94
},
"bright (>= 1000 DN)": {
"rms_px": 35.8,
"rms_avg": 2.53
}
},
{
"source": "12-bit",
"distance": 0.5,
"size_mb": 2.13,
"dark (< 300 DN)": {
"rms_px": 14.29,
"rms_avg": 0.92
},
"mid (300-1000 DN)": {
"rms_px": 20.81,
"rms_avg": 1.42
},
"bright (>= 1000 DN)": {
"rms_px": 39.92,
"rms_avg": 3.79
}
},
{
"source": "12-bit",
"distance": 1.0,
"size_mb": 0.92,
"dark (< 300 DN)": {
"rms_px": 16.77,
"rms_avg": 1.64
},
"mid (300-1000 DN)": {
"rms_px": 24.29,
"rms_avg": 2.56
},
"bright (>= 1000 DN)": {
"rms_px": 43.02,
"rms_avg": 5.98
}
},
{
"source": "8-bit",
"distance": 0,
"size_mb": 15.53,
"dark (< 300 DN)": {
"rms_px": 4.6,
"rms_avg": 0.64
},
"mid (300-1000 DN)": {
"rms_px": 4.61,
"rms_avg": 0.63
},
"bright (>= 1000 DN)": {
"rms_px": 4.61,
"rms_avg": 0.63
}
},
{
"source": "8-bit",
"distance": 0.3,
"size_mb": 2.51,
"dark (< 300 DN)": {
"rms_px": 17.35,
"rms_avg": 1.6
},
"mid (300-1000 DN)": {
"rms_px": 21.66,
"rms_avg": 1.53
},
"bright (>= 1000 DN)": {
"rms_px": 29.53,
"rms_avg": 1.59
}
},
{
"source": "8-bit",
"distance": 0.5,
"size_mb": 1.54,
"dark (< 300 DN)": {
"rms_px": 18.59,
"rms_avg": 2.18
},
"mid (300-1000 DN)": {
"rms_px": 23.9,
"rms_avg": 2.13
},
"bright (>= 1000 DN)": {
"rms_px": 34.86,
"rms_avg": 2.29
}
},
{
"source": "8-bit",
"distance": 1.0,
"size_mb": 0.63,
"dark (< 300 DN)": {
"rms_px": 20.45,
"rms_avg": 3.7
},
"mid (300-1000 DN)": {
"rms_px": 26.71,
"rms_avg": 3.44
},
"bright (>= 1000 DN)": {
"rms_px": 41.05,
"rms_avg": 3.99
}
},
{
"source": "8-bit values, 12-bit file",
"distance": 0,
"size_mb": 39.04,
"dark (< 300 DN)": {
"rms_px": 4.6,
"rms_avg": 0.64
},
"mid (300-1000 DN)": {
"rms_px": 4.61,
"rms_avg": 0.63
},
"bright (>= 1000 DN)": {
"rms_px": 4.61,
"rms_avg": 0.63
}
},
{
"source": "8-bit values, 12-bit file",
"distance": 0.3,
"size_mb": 3.5,
"dark (< 300 DN)": {
"rms_px": 12.72,
"rms_avg": 0.88
},
"mid (300-1000 DN)": {
"rms_px": 18.29,
"rms_avg": 1.14
},
"bright (>= 1000 DN)": {
"rms_px": 35.88,
"rms_avg": 2.61
}
},
{
"source": "8-bit values, 12-bit file",
"distance": 0.5,
"size_mb": 2.19,
"dark (< 300 DN)": {
"rms_px": 14.55,
"rms_avg": 1.12
},
"mid (300-1000 DN)": {
"rms_px": 20.99,
"rms_avg": 1.57
},
"bright (>= 1000 DN)": {
"rms_px": 39.96,
"rms_avg": 3.83
}
},
{
"source": "8-bit values, 12-bit file",
"distance": 1.0,
"size_mb": 0.94,
"dark (< 300 DN)": {
"rms_px": 16.87,
"rms_avg": 1.76
},
"mid (300-1000 DN)": {
"rms_px": 24.36,
"rms_avg": 2.62
},
"bright (>= 1000 DN)": {
"rms_px": 43.03,
"rms_avg": 6.01
}
}
]
}

View File

@ -25,12 +25,16 @@ tests; waiting for exposure and gain to stop changing never did.
The extra bits themselves add less than expected. In the daylight scene measured, the sensor's own The extra bits themselves add less than expected. In the daylight scene measured, the sensor's own
noise was 6–17 times larger than the error from rounding to 8 bits, which raised the total noise by noise was 6–17 times larger than the error from rounding to 8 bits, which raised the total noise by
at most 1.5%; and JPEG XL compression at distance 1.0 adds about 13 times more error than that at most 1.5%; and JPEG XL compression at distance 1.0 adds about 13 times more error than that
rounding. 12 bits are expected to make a meaningful difference only where part of the image is dark rounding. A dark, high-contrast scene from the Stäffelsberg tower (shaded forest under a hazy sky)
while the gain is low, as in high-contrast scenes (a bright sky over shaded forest or a backlit ridge was measured as well: there the noise in the dark third of the frame is below one 8-bit step, and
at dawn or dusk), where faint smoke over dark terrain is a small change in a dark value; and only if rounding to 8 bits raises it by 10–26%, but averaged over an area the 8-bit values still keep the
those values are saved with little or no loss. These conditions were not measured. The 12-bit format local brightness to 0.6 DN (12-bit scale). The larger difference comes from the encoder: saved at
costs little against 8-bit raw (same file size, about 10% more processing time), but the camera distance 1.0, an 8-bit file breaks the shaded forest into flat blocks and keeps its local brightness
runs at 9.86 instead of 14.75 fps. 2.3 times less accurately than a 12-bit file, and today's RGB8 file shows the same blocks at the same
size as the 12-bit file. The same 8-bit values saved as a 12-bit file behave almost like 12-bit data,
so the gain comes mostly from the file's declared bit depth, not from the four extra bits. The
12-bit format costs little against 8-bit raw (about 10% more processing time; the same file size in
daylight, 46% larger in the dark scene), but the camera runs at 9.86 instead of 14.75 fps.
## Units and abbreviations ## Units and abbreviations
@ -340,6 +344,7 @@ session.*
| 4 Oct 16:20–16:41 | 111 | camera tests | 21 | tower 111's own camera connected but idle | | 4 Oct 16:20–16:41 | 111 | camera tests | 21 | tower 111's own camera connected but idle |
| 4 Oct 16:41–18:19 | 111 | processing, pile-up, heat | 98 | | | 4 Oct 16:41–18:19 | 111 | processing, pile-up, heat | 98 | |
| 4 Oct 23:03–23:32 | 121 and 111 | processing on the shared frame, both towers simultaneously | 29 | no camera needed | | 4 Oct 23:03–23:32 | 121 and 111 | processing on the shared frame, both towers simultaneously | 29 | no camera needed |
| 7 Oct, morning | Stäffelsberg | one `BayerRG12p` and one RGB8 frame of a dark, high-contrast view (Section 3.9.2) | – | hazy; gain and exposure not recorded |
--- ---
@ -617,6 +622,13 @@ Full-size `.jxl` files are kept outside the repository in `tools/camera-bench/ex
### 3.9 Information content of the 12-bit data ### 3.9 Information content of the 12-bit data
The extra four bits were examined in two scenes: the daylight scene of the main campaign (2 and
4 October, Section 3.9.1), where noise is large, and a dark, high-contrast scene photographed from
the Stäffelsberg tower on 7 October (Section 3.9.2), where large parts of the frame are dark at low
noise. Section 3.9.3 combines the two.
#### 3.9.1 Daylight scene (2 and 4 October)
![Noise vs brightness](images/raw12/noise_vs_signal.png) ![Noise vs brightness](images/raw12/noise_vs_signal.png)
***Figure 12.** Sensor noise σ (DN, 12-bit scale) against signal level (DN, 12-bit scale), measured ***Figure 12.** Sensor noise σ (DN, 12-bit scale) against signal level (DN, 12-bit scale), measured
@ -656,45 +668,195 @@ and the dropped bits hold real scene detail.
has a PSNR of 36.6 dB (Table 16), an RMS error of about 61 DN, which is 13 times the rounding error; has a PSNR of 36.6 dB (Table 16), an RMS error of about 61 DN, which is 13 times the rounding error;
even at distance 0.3 (40.3 dB) it is about 40 DN, and raw planes at distance 0.5 (47.2 dB) about even at distance 0.3 (40.3 dB) it is about 40 DN, and raw planes at distance 0.5 (47.2 dB) about
18 DN. Much of this error is noise the encoder discards, and JPEG XL spends relatively more 18 DN. Much of this error is noise the encoder discards, and JPEG XL spends relatively more
precision on dark tones, so the error in shadows is smaller than these averages; it was not measured precision on dark tones, so the error in shadows is smaller than these averages (measured on the
separately. Still, with lossy files the compression, not the bit depth, decides how much fine tonal dark scene in Table 17d). Still, with lossy files the compression, not the bit depth, decides how
detail survives. Correspondingly, 12-bit and 8-bit raw give the same file size at distance 1.0 much fine tonal detail survives. Correspondingly, 12-bit and 8-bit raw give the same file size at
(2.65 and 2.66 MB, Table 14). distance 1.0 on this scene (2.65 and 2.66 MB, Table 14).
**Where 12 bits are expected to matter.** The noise falls with the signal, while the 8-bit step
stays at 16 DN. Extrapolating Figure 12, the noise drops below one 8-bit step at about 300 DN at
0 dB gain, or 100 DN at ≈ 15 dB. The 8-bit data is linear (no gamma curve), so this region, the
darkest 2–7% of the range, holds only 6–19 of the 256 8-bit levels. Gain multiplies the noise, so at
high gain the noise exceeds one 8-bit step again almost everywhere. Table 17b combines these
observations.
***Table 17b.** Situations in which 12-bit data is expected to differ from 8-bit data. Signal in DN
on the 12-bit scale (0–4095). "Measured" refers to this campaign.*
| Situation | Signal (DN), gain (dB) | Noise against one 8-bit step | Expected effect of 12 bits | Measured |
|---|---|---|---|---|
| Daylight, mid-tones and highlights (lit terrain, sky) | 700–4095, 0–15 | 1.6–5 steps | none measurable: total noise +0.2–1.5% | yes |
| Shadows in a high-contrast scene at low gain: shaded forest under a bright sky, backlit ridges, valleys at dawn and dusk | below ≈ 300 at 0 dB, ≈ 100 at 15 dB | below 1 step | 8 bits merge neighbouring levels; 12 bits keep small changes in dark areas, such as thin smoke over dark terrain | no (extrapolated) |
| Uniformly dark scene at high gain: night, gain up to 48 dB | any, high (up to 48) | above 1 step (gain amplifies noise) | little, except in the very darkest values | no |
| Strong processing of dark areas: brightening by 3 stops (×8) or more, contrast stretching | dark areas, any | – | 12 bits delay visible banding | 2 stops (×4) only: no difference (Figure 13) |
| Saving lossy at distance 1.0 | any | compression error ≈ 61 DN ≫ rounding 4.6 DN | largely lost to compression; kept in lossless files | yes, overall PSNR only |
The case most relevant to fire detection is the second: in a scene exposed for a bright sky, smoke
rising in front of shaded forest is a small brightness increase in an already dark area, at low gain.
Whether 12 bits help there, and whether that benefit survives distance 1.0, requires a test on such a
scene (a dawn or dusk frame with dark terrain under a bright sky, compared as in Figures 12 and 13).
![8 vs 12 bit at 15 dB](images/raw12/examples/121/shadow_8_vs_12.png) ![8 vs 12 bit at 15 dB](images/raw12/examples/121/shadow_8_vs_12.png)
![8 vs 12 bit at 0 dB](images/raw12/examples/lowgain/shadow_8_vs_12.png) ![8 vs 12 bit at 0 dB](images/raw12/examples/lowgain/shadow_8_vs_12.png)
***Figure 13.** The same darker crop brightened by 2 stops (pixel values multiplied by 4), saved as ***Figure 13a.** The darkest textured crop of the daylight scene (mean ≈ 810 DN) brightened by
8-bit (left; the 12-bit value shifted down by 4 bits, i.e. divided by 16) and as 12-bit (right): (a, 2 stops (pixel values multiplied by 4), saved as 8-bit (left; the 12-bit value shifted down by
top) ≈ 15 dB gain, 227 distinct green levels in 8-bit against 3138 in 12-bit; (b, bottom) 0 dB gain. 4 bits, i.e. divided by 16) and as 12-bit (right): top, ≈ 15 dB gain, 227 distinct green levels in
No visible difference: the noise is larger than the extra levels.* 8-bit against 3138 in 12-bit; bottom, 0 dB gain. No visible difference: the noise is larger than the
extra levels.*
At 0 dB gain the same settings produce 25–30% smaller files and about 3 dB higher PSNR (less noise At 0 dB gain the same settings produce 25–30% smaller files and about 3 dB higher PSNR (less noise
to encode): 12-bit colour at d1.0 is 1.9 MB, PSNR 40.0 dB, SSIM 0.987. to encode): 12-bit colour at d1.0 is 1.9 MB, PSNR 40.0 dB, SSIM 0.987.
#### 3.9.2 Dark, high-contrast scene (Stäffelsberg, 7 October)
**Scene and method.** On the morning of 7 October 2026, an Alvium 1800 U-1620c (5328 × 3040) on the
Stäffelsberg tower took two frames of the same view, one after the other: hazy sky and a distant valley above a
slope of shaded forest (Figure 13b). One was taken as `BayerRG12p`, converted to colour by the camera's
viewer program and saved as a 16-bit TIFF (the 12-bit values in the top 12 bits); the other as the camera's
RGB8 (today's format), saved as PNG. Gain and exposure were not recorded with the files. The 12-bit
frame spans 128–2792 DN (green), with nothing clipped: a third of it is darker than 300 DN, the
region where Section 3.9.1 expected 12 bits to matter.
The camera's RGB8 frame is a separate capture (trees move between frames) made by the camera's own
colour processing, so it is compared by eye and by statistics only. For an exact comparison of bit
depth, an **8-bit version of the same frame** was made by keeping the top 8 of the 12 bits (the
12-bit value divided by 16, rounded down, as `BayerRG8` delivers), and compared pixel by pixel with
the 12-bit original. The 12-bit frame was converted to colour by the viewer, not by the tower's
edge-aware conversion, so its noise is not directly comparable with Figure 12 (measured on the raw
green pixels); for comparison, the October frames' lossless 12-bit colour files were measured in
the same way. Compression used libjxl 0.12.0 on the laptop with the tower's settings (effort 3;
12-bit and 8-bit input as declared by their bit depth), single runs. Script:
`tools/camera-bench/compare_8_vs_12.py`; results in
`docs/images/raw12/examples/staeffelsberg/results.json`.
![Stäffelsberg overview](images/raw12/examples/staeffelsberg/overview.jpg)
***Figure 13b.** The Stäffelsberg 12-bit frame, shown upright (the frame is rotated by 90° so that
the sky is at the top), at a quarter of full size and with a display brightness curve (sRGB) so that
the forest is visible. Blue: the dark crop of Figures 13d and 13f (shaded forest, mean 194 DN).
Red: the mid-tone crop of Figure 13e (distant valley in haze, mean 1606 DN).*
**Noise.** The noise in each colour image was measured as the standard deviation of each pixel
around its local average (a Gaussian average over about 2 pixels), in 24 × 24-pixel patches grouped
by brightness, taking the quietest 10% of each group as in Section 2.11; the result was calibrated
on generated noise of known σ.
![Noise vs brightness, Stäffelsberg](images/raw12/examples/staeffelsberg/noise_vs_signal.png)
***Figure 13c.** Noise σ (DN, 12-bit scale) against signal (DN, 12-bit scale, green) in the colour
images: the Stäffelsberg 12-bit frame (blue), its 8-bit version (orange), the camera's RGB8 frame of
the same view (purple, another capture, multiplied by 16 to the 12-bit scale), and the October 0 dB
and ≈ 15 dB frames measured the same way (dashed). Dashed line: one 8-bit step (16 DN); dotted line:
the RMS error of rounding to 8 bits (4.6 DN). The bump at ≈ 340 DN is scene texture that remains
even in the quietest patches.*
***Table 17c.** Noise σ (DN, 12-bit scale) in the Stäffelsberg colour images at selected signal
levels (DN, 12-bit scale, green). In 8-bit steps: the 12-bit noise divided by 16; below 1, an 8-bit
value cannot record the variation the sensor delivers. Increase: how much rounding to 8 bits raises
the noise. Camera RGB8: another capture, the camera's own colour processing.*
| Signal (DN) | 12-bit noise (DN) | In 8-bit steps | 8-bit version, same frame (DN) | Increase from rounding | Camera RGB8 (DN) |
|---|---|---|---|---|---|
| ≈ 160 | 5.4 | 0.34 | 6.4 | +19% | 9.6 |
| ≈ 185 | 6.2 | 0.39 | 7.8 | +26% | 10.5 |
| ≈ 230 | 7.9 | 0.49 | 9.3 | +18% | 12.2 |
| ≈ 275 | 10.4 | 0.65 | 11.4 | +10% | 14.6 |
| ≈ 450 | 11.2 | 0.70 | 12.3 | +10% | 15.8 |
| ≈ 740 | 13.9 | 0.87 | 14.8 | +6% | 23.1 |
| ≈ 1300 | 16.2 | 1.01 | 16.9 | +4% | 24.2 |
| ≈ 2250 | 20.5 | 1.28 | 21.0 | +2% | 32.0 |
In this frame the noise stays below one 8-bit step up to about 1300 DN, which covers about 70% of
the frame. At ≈ 700 DN the October frames, measured the same way, have 17 DN (0 dB) and 33 DN
(≈ 15 dB) against 14 DN here, and they contain nothing darker. Rounding to 8 bits raises the noise
in the forest by 10–26%, against at most 1.5% in the daylight scene. The camera's RGB8 frame is
1.4–1.8 times noisier than the 12-bit frame at every brightness, probably from the camera's own
colour processing; this is a difference of the camera's conversion, not of bit depth.
**Levels and visibility.** The 400 × 400-pixel dark crop (green 129–557 DN) uses 272 distinct green
levels in 12-bit, 25 in its 8-bit version and 22 in the camera's RGB8.
![Dark crop, 8 vs 12 bit](images/raw12/examples/staeffelsberg/dark_crop.png)
***Figure 13d.** The dark crop (shaded forest, mean 194 DN) at 100%, brightened by 2 stops (pixel
values ×4, top) and 4 stops (×16, bottom). Left: the camera's RGB8 frame (another capture, taken
separately; branches moved). Middle: the 8-bit version of the 12-bit frame (top 8 bits). Right:
the 12-bit frame. Not compressed.*
![Mid-tone crop, 8 vs 12 bit](images/raw12/examples/staeffelsberg/mid_crop.png)
***Figure 13e.** Control: the mid-tone crop (distant valley in haze, mean 1606 DN, noise about one
8-bit step) with its contrast stretched 4 times around the crop's mean, 8-bit version (left) and
12-bit (right). Not compressed. No visible difference.*
Uncompressed, the 8-bit forest is slightly grainier than the 12-bit one at +4 stops, with no banding
(Figure 13d): even at 0.3–0.5 of a step the noise still dithers the rounding. Averaged over about
200 pixels (a Gaussian of 4 pixels), the 8-bit version differs from the 12-bit frame by 0.6 DN RMS
in every brightness range (Table 17d): the local brightness, which is what a faint, even haze such as
thin smoke changes, survives rounding to within less than a tenth of an 8-bit step.
**Compression.** Each version was saved as JPEG XL and compared with the 12-bit frame in three
brightness regions: dark (below 300 DN, 33% of the frame), mid (300–1000 DN, 39%) and bright
(1000 DN and above, 28%). Two errors are given: per pixel, which includes noise the encoder
discards; and of the local average, after averaging both images over about 200 pixels, which
measures lost scene brightness rather than lost noise. A third version tests the encoder rather than
the data: the same 8-bit values, stored as a 12-bit file (each value × 16 + 8).
***Table 17d.** File size (MB) and RMS error (DN, 12-bit scale) against the 12-bit frame, per
brightness region, for the 12-bit frame, its 8-bit version, and its 8-bit values stored in a 12-bit
file, at JPEG XL distance 0 (lossless), 0.3, 0.5 and 1.0, effort 3. Per pixel: the error at each
pixel. Local average: the error after averaging both images over about 200 pixels (Gaussian,
σ = 4 pixels). Today's file (camera RGB8, d0.8, effort 4) is 0.92 MB; the camera RGB8 frame at d1.0
is 0.66 MB (another capture, so no error can be computed).*
| File | Distance | Size (MB) | Per pixel, dark / mid / bright (DN) | Local average, dark / mid / bright (DN) |
|---|---|---|---|---|
| 12-bit | lossless | 38.6 | 0 / 0 / 0 | 0 / 0 / 0 |
| 12-bit | 0.3 | 3.40 | 12.3 / 18.0 / 35.8 | 0.6 / 0.9 / 2.5 |
| 12-bit | 0.5 | 2.13 | 14.3 / 20.8 / 39.9 | 0.9 / 1.4 / 3.8 |
| **12-bit** | **1.0** | **0.92** | **16.8 / 24.3 / 43.0** | **1.6 / 2.6 / 6.0** |
| 8-bit | lossless | 15.5 | 4.6 / 4.6 / 4.6 | 0.6 / 0.6 / 0.6 |
| 8-bit | 0.3 | 2.51 | 17.4 / 21.7 / 29.5 | 1.6 / 1.5 / 1.6 |
| 8-bit | 0.5 | 1.54 | 18.6 / 23.9 / 34.9 | 2.2 / 2.1 / 2.3 |
| **8-bit** | **1.0** | **0.63** | **20.4 / 26.7 / 41.0** | **3.7 / 3.4 / 4.0** |
| 8-bit values, 12-bit file | lossless | 39.0 | 4.6 / 4.6 / 4.6 | 0.6 / 0.6 / 0.6 |
| 8-bit values, 12-bit file | 0.3 | 3.50 | 12.7 / 18.3 / 35.9 | 0.9 / 1.1 / 2.6 |
| 8-bit values, 12-bit file | 0.5 | 2.19 | 14.6 / 21.0 / 40.0 | 1.1 / 1.6 / 3.8 |
| **8-bit values, 12-bit file** | **1.0** | **0.94** | **16.9 / 24.4 / 43.0** | **1.8 / 2.6 / 6.0** |
![Dark crop after compression](images/raw12/examples/staeffelsberg/dark_compressed.png)
***Figure 13f.** The dark crop of Figure 13d after saving, brightened by 4 stops (×16): (a) the
camera's RGB8 frame saved as today (distance 0.8, effort 4; another capture); (b) the 8-bit version
saved as an 8-bit file at distance 1.0; (c) the same 8-bit values saved as a 12-bit file at distance
1.0; (d) the 12-bit frame at distance 1.0 (the proposed setting). Effort 3 unless stated.*
At distance 1.0, the 8-bit file loses the shadow detail: the forest breaks into flat blocks
(Figure 13f b), and the local brightness in the dark region is off by 3.7 DN against 1.6 DN for the
12-bit file. Today's RGB8 file shows the same blocks (Figure 13f a), although it is as large as the
12-bit file (0.92 MB). In the bright region the order reverses (4.0 against 6.0 DN): an 8-bit file
gets about the same error at every brightness, a 12-bit file a smaller error in the dark and a larger
one in the bright.
This is a property of the encoder, not of the four extra bits: the same 8-bit values saved as a
12-bit file behave almost exactly like the 12-bit frame (1.8 against 1.6 DN in the dark, 0.94 against
0.92 MB, Figure 13f c). How the bits are spent depends on the bit depth the file declares. As a
consequence, on this scene the 12-bit file at distance 1.0 is 46% larger than the 8-bit file
(0.92 against 0.63 MB), whereas in the daylight scene the two were the same size (Table 14); dark
frames are in any case far smaller than the 2.65 MB of the daylight frame used for the storage
estimate (Section 3.12).
In summary, for this dark scene: (1) the four extra bits themselves change little after saving:
1.6 against 1.8 DN of local-brightness error in the dark at distance 1.0, and 0.6 DN when 8-bit
values are kept losslessly; (2) saving as a 12-bit file keeps about 2.3 times more accurate shadow
brightness than saving as an 8-bit file at the same distance, and visibly more shadow detail than
today's RGB8 file at the same size; (3) per pixel, rounding to 8 bits raises the noise in the dark
by 10–26%, which matters only for lossless or near-lossless files.
#### 3.9.3 Where 12 bits matter
The noise falls with the signal, while the 8-bit step stays at 16 DN. In the daylight scene the
noise was above one 8-bit step everywhere (Figure 12); in the Stäffelsberg scene it was below one
step up to about 1300 DN, over 70% of the frame (Figure 13c). Gain multiplies the noise, so at high
gain the noise exceeds one 8-bit step again almost everywhere. Table 17b combines these
observations.
***Table 17b.** Situations in which 12-bit data differs from 8-bit data. Signal in DN on the 12-bit
scale (0–4095). "Measured" refers to this campaign.*
| Situation | Signal (DN), gain (dB) | Noise against one 8-bit step | Effect of 12 bits | Measured |
|---|---|---|---|---|
| Daylight, mid-tones and highlights (lit terrain, sky) | 700–4095, 0–15 | 1.6–5 steps | none measurable: total noise +0.2–1.5% | yes (3.9.1) |
| Shadows in a high-contrast scene at low gain: shaded forest under a bright sky, backlit ridges, valleys at dawn and dusk | below ≈ 1300 in the Stäffelsberg frame | 0.3–1 step | 25 instead of 272 levels in the dark crop, +10–26% noise per pixel; local brightness kept by 8-bit values to 0.6 DN; no visible banding at +4 stops | yes (3.9.2) |
| Uniformly dark scene at high gain: night, gain up to 48 dB | any, high (up to 48) | above 1 step (gain amplifies noise) | little, except in the very darkest values | no |
| Strong processing of dark areas: brightening by 3 stops (×8) or more, contrast stretching | dark areas, any | – | 12 bits delay visible banding | +2 stops in daylight, +4 stops at Stäffelsberg: no banding, 8-bit slightly grainier (Figures 13a, 13d) |
| Saving lossy at distance 1.0 | any | compression error ≈ 17–61 DN per pixel ≫ rounding 4.6 DN | an 8-bit file loses shadow detail (blocks) and 2.3 times more local brightness in the dark than a 12-bit file; caused by the declared file bit depth, not by the missing bits | yes (3.9.2, Table 17d) |
The case most relevant to fire detection is the second: smoke rising in front of shaded forest is a
small brightness increase in an already dark area, at low gain. The Stäffelsberg frame is such a
scene. There, the proposed 12-bit file at distance 1.0 keeps the local brightness of the shaded
forest to 1.6 DN RMS and its texture visibly better than today's RGB8 file of the same size; most of
that gain comes from saving a 12-bit file, and would also be available to 8-bit data saved as a
12-bit file. Whether these differences change smoke detection was not measured.
### 3.10 Saving throughput and pile-up ### 3.10 Saving throughput and pile-up
![Saves per second, same frame](images/raw12/pace_common.png) ![Saves per second, same frame](images/raw12/pace_common.png)
@ -797,7 +959,7 @@ The towers have 11–22 GB of free storage, so images must leave the tower conti
| Processing | Unpack + colour conversion 38 ms (IOTA) / 50 ms (Delta); a 12-bit save 518 / 820 ms, slightly faster than today's RGB8 save (567 / 872 ms). | 3.5 | | Processing | Unpack + colour conversion 38 ms (IOTA) / 50 ms (Delta); a 12-bit save 518 / 820 ms, slightly faster than today's RGB8 save (567 / 872 ms). | 3.5 |
| Computers | The Delta takes 1.6 times as long on the identical frame. | 3.5 | | Computers | The Delta takes 1.6 times as long on the identical frame. | 3.5 |
| Quality | d1.0: 2.65 MB, SSIM 0.973. Raw planes keep sensor values much closer (47 vs 39 dB at 6 MB). | 3.7 | | Quality | d1.0: 2.65 MB, SSIM 0.973. Raw planes keep sensor values much closer (47 vs 39 dB at 6 MB). | 3.7 |
| 12 bits | In daylight, rounding to 8 bits adds at most 1.5% to the noise, and compression at d1.0 adds 13 times more error than rounding. 12 bits are expected to matter only in dark areas at low gain (high-contrast scenes), and only in lightly compressed files; not measured. | 3.9 | | 12 bits | In daylight, rounding to 8 bits adds at most 1.5% to the noise, and compression at d1.0 adds 13 times more error than rounding. In a dark, high-contrast scene (Stäffelsberg) rounding adds 10–26% noise in the shadows; at d1.0 an 8-bit file loses shadow detail and 2.3 times more shadow brightness than a 12-bit file, mostly because of the file's declared bit depth, not the extra bits. Today's RGB8 file shows the same loss. | 3.9 |
| Throughput | IOTA keeps up with a frame every 0.7 s with one worker; the Delta needs two. | 3.10 | | Throughput | IOTA keeps up with a frame every 0.7 s with one worker; the Delta needs two. | 3.10 |
| Heat | One frame every 2 s: IOTA 53 °C / 5.1 W, Delta 66 °C / 5.7 W. | 3.11 | | Heat | One frame every 2 s: IOTA 53 °C / 5.1 W, Delta 66 °C / 5.7 W. | 3.11 |
| Storage | 12-bit colour d1.0: 2.7 MB per frame, below today's 3.3–4.0 MB. | 3.12 | | Storage | 12-bit colour d1.0: 2.7 MB per frame, below today's 3.3–4.0 MB. | 3.12 |
@ -831,6 +993,10 @@ between neighbouring waypoints, which decides the real pace, remains to be measu
- One scene on two afternoons, at ≈ 15 dB gain in the main runs, plus one 0 dB frame. Night - One scene on two afternoons, at ≈ 15 dB gain in the main runs, plus one 0 dB frame. Night
conditions were not measured. conditions were not measured.
- The dark-scene comparison (Section 3.9.2) rests on one pair of frames, without recorded gain and
exposure; its 12-bit frame was converted to colour by the camera's viewer, not by the tower, and
compressed on a laptop (libjxl 0.12.0) rather than on a tower (0.11). Its 8-bit version is the
12-bit colour image cut to 8 bits, not a `BayerRG8` frame converted to colour.
- Auto-exposure steps were forced on a still camera, not caused by real slews. Real waypoint-to- - Auto-exposure steps were forced on a still camera, not caused by real slews. Real waypoint-to-
waypoint changes are usually much smaller than ×16, so real settling should be shorter. waypoint changes are usually much smaller than ×16, so real settling should be shorter.
- The tower program was stopped, so its own load (camera stream, control loop) is not included; the - The tower program was stopped, so its own load (camera stream, control loop) is not included; the
@ -854,9 +1020,11 @@ between neighbouring waypoints, which decides the real pace, remains to be measu
4. **Keep a waypoint's frame when brightness is in the camera's target band and steady for 3 4. **Keep a waypoint's frame when brightness is in the camera's target band and steady for 3
frames**, with a time limit. frames**, with a time limit.
5. **12-bit versus raw 8-bit is a choice, not a cost.** `BayerRG8` (14.75 fps) settles about 1 s 5. **12-bit versus raw 8-bit is a choice, not a cost.** `BayerRG8` (14.75 fps) settles about 1 s
faster in daylight and loses nothing measurable there; `BayerRG12p` is expected to help in dark areas faster in daylight and loses nothing measurable there. In the dark areas of a high-contrast scene
of high-contrast scenes at low gain, such as shaded terrain under a bright sky at dawn or dusk (Section 3.9.2), a 12-bit file at distance 1.0 keeps clearly more shadow detail than an 8-bit
(Section 3.9), which remains to be tested. The pixel format is part of the camera profile, so the program should accept both. file, but mainly because of how the file is encoded: if `BayerRG8` is used, its colour image
should be saved as a 12-bit (or 16-bit) JPEG XL file, which keeps nearly the same shadow detail.
The pixel format is part of the camera profile, so the program should accept both.
6. **Store raw 12-bit planes losslessly (17–18 MB per frame) only if the detection pipeline needs 6. **Store raw 12-bit planes losslessly (17–18 MB per frame) only if the detection pipeline needs
exact sensor values**; it requires about 6 times the storage. exact sensor values**; it requires about 6 times the storage.
@ -867,7 +1035,8 @@ between neighbouring waypoints, which decides the real pace, remains to be measu
1. ISO 19567-2:2019. *Photography — Digital cameras — Texture reproduction measurements — Part 2: 1. ISO 19567-2:2019. *Photography — Digital cameras — Texture reproduction measurements — Part 2:
Measurements using dead leaves pattern.* International Organization for Standardization. Measurements using dead leaves pattern.* International Organization for Standardization.
2. ISO/IEC 18181. *Information technology — JPEG XL image coding system*; reference implementation 2. ISO/IEC 18181. *Information technology — JPEG XL image coding system*; reference implementation
libjxl, versions 0.11.0 and 0.11.1, https://github.com/libjxl/libjxl. libjxl, versions 0.11.0 and 0.11.1 (towers) and 0.12.0 (dark-scene comparison, Section 3.9.2),
https://github.com/libjxl/libjxl.
3. OpenCV, versions 4.5.4 and 4.10: `cvtColor` colour conversions, Bayer demosaicing (bilinear and 3. OpenCV, versions 4.5.4 and 4.10: `cvtColor` colour conversions, Bayer demosaicing (bilinear and
edge-aware), https://docs.opencv.org. edge-aware), https://docs.opencv.org.
4. EMVA. *GenICam Pixel Format Naming Convention (PFNC)* and *GenICam Standard Features Naming 4. EMVA. *GenICam Pixel Format Naming Convention (PFNC)* and *GenICam Standard Features Naming
@ -892,6 +1061,10 @@ between neighbouring waypoints, which decides the real pace, remains to be measu
`tools/camera-bench/examples-*`. `tools/camera-bench/examples-*`.
- Figures: `plot_results.py`, `plot_common.py` (charts), `make_figures.py` (example crops); - Figures: `plot_results.py`, `plot_common.py` (charts), `make_figures.py` (example crops);
quality scores: `compare_examples.py`. quality scores: `compare_examples.py`.
- Dark-scene comparison (Section 3.9.2): `compare_8_vs_12.py`; figures and
[results.json](images/raw12/examples/staeffelsberg/results.json) in
`docs/images/raw12/examples/staeffelsberg/`. The two source frames (`BayerRG12p.tif`, 97 MB;
`RGB8.png`, 17 MB) are kept outside the repository, in `8bit_vs_12bit_test/` next to it.
## Appendix B. Processing times on each tower's own frame ## Appendix B. Processing times on each tower's own frame

View File

@ -4,7 +4,7 @@ This file gives an AI assistant the background needed to discuss the report
[raw12-pipeline-measurements.md](raw12-pipeline-measurements.md): what was asked, how the tests were [raw12-pipeline-measurements.md](raw12-pipeline-measurements.md): what was asked, how the tests were
run, what the numbers mean, what was decided, which points have been questioned, and what is still run, what the numbers mean, what was decided, which points have been questioned, and what is still
open. Read the report itself for the full tables and figures; this file explains around them. open. Read the report itself for the full tables and figures; this file explains around them.
State as of 2026-10-05, branch `raw12-capture`. State as of 2026-10-07, branch `raw12-capture` (dark-scene comparison added 2026-10-07).
## 1. The system in one paragraph ## 1. The system in one paragraph
@ -151,9 +151,26 @@ have 11–22 GB free, so images must leave the tower continuously.
backlit ridges under a bright sky at dawn or dusk; thin smoke over dark terrain is the relevant backlit ridges under a bright sky at dawn or dusk; thin smoke over dark terrain is the relevant
fire-detection case. At night with high gain, noise is amplified above the step again, so little fire-detection case. At night with high gain, noise is amplified above the step again, so little
benefit. Strong shadow lifting (≥ 3 stops) untested; 2 stops showed no difference. benefit. Strong shadow lifting (≥ 3 stops) untested; 2 stops showed no difference.
- None of the dark cases were measured (the darkest area in the scene was ≈ 700 DN; a dusk attempt - The October scene had nothing darker than ≈ 700 DN (a dusk attempt on 121 needed 24 dB and was
on 121 needed 24 dB and was discarded). Proposed test: a dawn/dusk frame with dark terrain under a discarded). On 7 Oct the user took a dark, high-contrast pair at the Stäffelsberg tower (shaded
bright sky, compared as in Figures 12 and 13. forest under hazy sky, same 1620c model): `BayerRG12p` converted to colour by the camera viewer and
saved as 16-bit TIFF (12-bit values << 4), and a separate RGB8 capture as PNG; files in
`../8bit_vs_12bit_test/` (outside the repo). Gain/exposure not recorded. Report Section 3.9.2,
script `tools/camera-bench/compare_8_vs_12.py`, results.json in
`docs/images/raw12/examples/staeffelsberg/`.
- Stäffelsberg findings: 33% of the frame < 300 DN; noise in the colour image (std around a local
mean, quietest 10% of 24 × 24 patches, calibrated) 5–15 DN below ≈ 1300 DN, i.e. below one 8-bit
step over ~70% of the frame. Rounding to 8 bits: +10–26% noise in the forest; dark crop 272 vs 25
green levels (camera RGB8 22); local-average error (Gaussian σ 4 px) only 0.6 DN; no visible
banding at +4 stops, 8-bit slightly grainier. Camera RGB8 1.4–1.8× noisier than the 12-bit colour
image (camera's own processing).
- Compression is where it shows: at d1.0 the 8-bit file has 3.7 DN local-average error in the dark
vs 1.6 DN for 12-bit (bright: 4.0 vs 6.0), visibly blocky shadows; today's RGB8 d0.8 e4 file
(0.92 MB) is equally blocky. The same 8-bit values stored in a 12-bit file give 1.8 DN and
0.94 MB: the effect is libjxl spending bits by declared bit depth, not the four extra bits. Sizes
on this scene at d1.0: 12-bit 0.92 MB, 8-bit 0.63 MB (+46%; equal in daylight). Implication: if
BayerRG8 were ever used, save its colour as a 12/16-bit JPEG XL.
- Not yet tested: real smoke; night at high gain; the tower's own de-Bayer on a dark frame.
- 12-bit vs 8-bit raw costs little in saving (same size, ~10% more time) but the camera runs at - 12-bit vs 8-bit raw costs little in saving (same size, ~10% more time) but the camera runs at
9.86 vs 14.75 fps, ~1 s slower settling in daylight. The main gain over today is frame rate, not 9.86 vs 14.75 fps, ~1 s slower settling in daylight. The main gain over today is frame rate, not
bit depth. bit depth.
@ -178,7 +195,7 @@ N workers, bounded queue; scheduler save gate pauses the scan when the queue is
Not done yet: exporting the new camera profiles on a tower (the 1620c is currently on tower 111 Not done yet: exporting the new camera profiles on a tower (the 1620c is currently on tower 111
and must be moved back to 121 first); field verification with real slews; checking that downstream and must be moved back to 121 first); field verification with real slews; checking that downstream
consumers decode 12-bit files; the dawn/dusk 12-bit test. The branch is deployed on tower 111, consumers decode 12-bit files. The branch is deployed on tower 111,
whose boot service still passes old `--init/--start` flags and needs `scripts/install-tower`. whose boot service still passes old `--init/--start` flags and needs `scripts/install-tower`.
## 10. Report structure (for orientation) ## 10. Report structure (for orientation)
@ -187,7 +204,8 @@ Abstract; Units and abbreviations; 1 Introduction (1.1 question, 1.2 pixel forma
mosaic vs raw planes, Figures 1a/1b, Table 1; 1.3 earlier work); 2 Materials and methods (2.1–2.12, mosaic vs raw planes, Figures 1a/1b, Table 1; 1.3 earlier work); 2 Materials and methods (2.1–2.12,
Tables 2–8, Figure 2); 3 Results (3.1 frame rate, 3.2 auto-exposure, 3.3 keep rules, 3.4 white Tables 2–8, Figure 2); 3 Results (3.1 frame rate, 3.2 auto-exposure, 3.3 keep rules, 3.4 white
balance, 3.5 processing per step, 3.6 effort and threads, 3.7 quality setting, 3.8 example images, balance, 3.5 processing per step, 3.6 effort and threads, 3.7 quality setting, 3.8 example images,
3.9 information content of 12-bit data with Tables 17a/17b, 3.10 throughput, 3.11 heat, 3.9 information content of 12-bit data (3.9.1 daylight, 3.9.2 Stäffelsberg dark scene, 3.9.3
summary; Figures 12, 13a–13f, Tables 17a–17d), 3.10 throughput, 3.11 heat,
3.12 storage; Figures 3–17, Tables 9–20); 4 Discussion (summary Table 21, pace Table 22, 3.12 storage; Figures 3–17, Tables 9–20); 4 Discussion (summary Table 21, pace Table 22,
limitations); 5 Conclusions and six recommendations; References [1]–[7]; Appendix A raw data and limitations); 5 Conclusions and six recommendations; References [1]–[7]; Appendix A raw data and
tools; Appendix B per-tower processing times. Figures live in `docs/images/raw12/`. tools; Appendix B per-tower processing times. Figures live in `docs/images/raw12/`.

View File

@ -22,6 +22,7 @@ Not part of the build: CMake only compiles the tools it names.
| `run_common_frame.sh` | the processing tests on one shared frame (`common-frame/`, git-ignored, rebuilt from 121's lossless 12-bit example), so towers compare exactly (~25–40 min) | | `run_common_frame.sh` | the processing tests on one shared frame (`common-frame/`, git-ignored, rebuilt from 121's lossless 12-bit example), so towers compare exactly (~25–40 min) |
| `plot_common.py` | the shared-frame tables and charts (per-step time, size per quality setting, saves per second); laptop venv | | `plot_common.py` | the shared-frame tables and charts (per-step time, size per quality setting, saves per second); laptop venv |
| `compare_examples.py` | scores every 12-bit example against the lossless 12-bit colour image (PSNR, SSIM); laptop venv | | `compare_examples.py` | scores every 12-bit example against the lossless 12-bit colour image (PSNR, SSIM); laptop venv |
| `compare_8_vs_12.py` | 8-bit against 12-bit on a dark scene: noise, levels, JPEG XL error per brightness region, crop figures (2026-10-07); laptop venv |
| `make_figures.py` | example-image figures (crops, difference maps, 8 vs 12-bit shadow) from the example `.jxl` files; laptop venv | | `make_figures.py` | example-image figures (crops, difference maps, 8 vs 12-bit shadow) from the example `.jxl` files; laptop venv |
| `plot_results.py` | every chart and table of `docs/raw12-pipeline-measurements.md` from the results folders; laptop venv | | `plot_results.py` | every chart and table of `docs/raw12-pipeline-measurements.md` from the results folders; laptop venv |
| `results-*/` | raw results of each run, one folder per tower and day | | `results-*/` | raw results of each run, one folder per tower and day |

View File

@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""compare_8_vs_12.py - 8-bit against 12-bit on a dark, high-contrast scene.
~/.venvs/fgc-plots/bin/python compare_8_vs_12.py <BayerRG12p.tif> <RGB8.png> <out_dir> \
[<october lossless c12 .jxl> <label> ...]
Inputs: the camera's BayerRG12p frame converted to colour by the viewer and saved as 16-bit TIFF
(12-bit values in the top bits), and the camera's own RGB8 frame of the same view taken seconds
later (another capture: trees move, so no pixel-by-pixel comparison with it). The 8-bit version of
the same frame is the 12-bit value >> 4 (what BayerRG8 would deliver). Optional extra lossless
12-bit colour files from earlier campaigns get the same noise measurement, for comparison.
Writes to <out_dir>:
overview.jpg the frame upright, display gamma, crop boxes
noise_vs_signal.png noise against brightness, every image measured the same way
dark_crop.png dark crop, camera RGB8 / 8-bit / 12-bit, +2 and +4 stops
dark_compressed.png dark crop after JPEG XL, +4 stops: today's camera RGB8 file (d0.8 e4),
and at d1.0 the 8-bit file, the 8-bit values in a 12-bit file, 12-bit
mid_crop.png mid-tone crop, 8-bit / 12-bit, contrast x4 around its mean (control)
results.json noise, levels, sizes and errors per brightness region
"""
import json
import os
import sys
import cv2
import imagecodecs
import matplotlib
import matplotlib.ticker
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
tif, png, out = sys.argv[1:4]
extra = list(zip(sys.argv[4::2], sys.argv[5::2]))
os.makedirs(out, exist_ok=True)
c12 = cv2.imread(tif, cv2.IMREAD_UNCHANGED)[:, :, ::-1] >> 4 # 0-4095, uint16
cam8 = cv2.imread(png, cv2.IMREAD_UNCHANGED)[:, :, ::-1].copy() # 0-255, uint8
c8 = (c12 >> 4).astype(np.uint8) # same frame, top 8 bits
c8in12 = c8.astype(np.uint16) * 16 + 8 # the same 8-bit values, declared as a 12-bit file
# (integers only: + 8 rather than + 7.5; the 0.5 is taken off again after decoding)
H, W = c12.shape[:2]
def to12(a):
"""Any decoded image on the 12-bit scale; 8-bit v stands for the middle of 16v ... 16v + 15."""
return a.astype(np.float32) * 16 + 7.5 if a.dtype == np.uint8 else a.astype(np.float32)
ref = c12.astype(np.float32)
lum_s = cv2.GaussianBlur(ref[..., 1].astype(np.float32), (0, 0), 8)
REGIONS = {"dark (< 300 DN)": lum_s < 300,
"mid (300-1000 DN)": (lum_s >= 300) & (lum_s < 1000),
"bright (>= 1000 DN)": lum_s >= 1000}
# --- noise against brightness -------------------------------------------------------------
P = 24
EDGES = np.array([100, 130, 160, 200, 250, 300, 400, 500, 700, 1000, 1400, 2000, 2400, 2800, 3300])
def noise_floor(g):
"""Per-pixel noise of a colour image's green channel, on the 12-bit scale.
Standard deviation of the residual after a Gaussian (sigma 2 px) local mean, per 24 x 24 patch;
patches grouped by brightness and the quietest 10% of each group averaged (texture only adds).
Plain standard deviation, not a median-based one, so it also works on 8-bit steps. Neighbouring
pixels of a de-Bayered image are correlated, which this measure tolerates better than
neighbour differences.
"""
g = g.astype(np.float32)
r = g - cv2.GaussianBlur(g, (0, 0), 2.0)
h, w = g.shape[0] // P * P, g.shape[1] // P * P
sd = r[:h, :w].reshape(h // P, P, w // P, P).std(axis=(1, 3)).ravel()
mn = g[:h, :w].reshape(h // P, P, w // P, P).mean(axis=(1, 3)).ravel()
rows = []
for a, b in zip(EDGES[:-1], EDGES[1:]):
m = (mn >= a) & (mn < b)
if m.sum() < 50:
continue
rows.append((float(np.median(mn[m])), float(np.sort(sd[m])[:max(1, m.sum() // 10)].mean())))
return rows
# The residual of white noise under this high-pass is a little below its true sigma: calibrate.
wn = np.random.default_rng(0).normal(0, 10, (1200, 1200)) + 1000
CAL = 10 / noise_floor(wn)[0][1]
curves = {"12-bit (this frame)": [(s, n * CAL) for s, n in noise_floor(ref[..., 1])],
"8-bit, same frame (12-bit >> 4)": [(s, n * CAL) for s, n in noise_floor(to12(c8)[..., 1])],
"camera RGB8 (other capture)": [(s, n * CAL) for s, n in noise_floor(to12(cam8)[..., 1])]}
for path, label in extra:
a = imagecodecs.jpegxl_decode(open(path, "rb").read()).astype(np.float32)
a = a / 16 if a.max() > 4095.5 else a
curves[label] = [(s, n * CAL) for s, n in noise_floor(a[..., 1])]
fig, ax = plt.subplots(figsize=(6.6, 3.6))
style = {"12-bit (this frame)": ("tab:blue", "-", "o"),
"8-bit, same frame (12-bit >> 4)": ("tab:orange", "-", "s"),
"camera RGB8 (other capture)": ("tab:purple", "-", "^")}
other = iter([("tab:green", "--", "."), ("tab:red", "--", ".")])
for label, rows in curves.items():
c, ls, mk = style.get(label) or next(other)
ax.plot([r[0] for r in rows], [r[1] for r in rows], color=c, ls=ls, marker=mk, ms=4, label=label)
ax.axhline(16, color="k", ls="--", lw=1, label="one 8-bit step (16)")
ax.axhline(16 / np.sqrt(12), color="gray", ls=":", lw=1, label="8-bit rounding error (4.6)")
ax.set_xscale("log"); ax.set_yscale("log")
ticks = matplotlib.ticker
ax.xaxis.set_major_locator(ticks.FixedLocator([150, 200, 300, 500, 700, 1000, 1500, 2000, 3000]))
ax.yaxis.set_major_locator(ticks.FixedLocator([5, 7, 10, 15, 20, 30, 50, 70]))
for axis in (ax.xaxis, ax.yaxis):
axis.set_major_formatter(ticks.ScalarFormatter()); axis.set_minor_formatter(ticks.NullFormatter())
ax.set_xlabel("signal, 12-bit units (green)"); ax.set_ylabel("noise σ, 12-bit units")
ax.set_title("Noise vs brightness in the colour image (quietest 10% of 24×24 patches)", fontsize=10)
ax.grid(True, which="both", alpha=0.3)
ax.legend(fontsize=7, loc="upper left")
fig.tight_layout(); fig.savefig(os.path.join(out, "noise_vs_signal.png"), dpi=100); plt.close(fig)
# --- JPEG XL at the tower's settings (effort 3), 12-bit and 8-bit of the same frame ----------
DIST = [0, 0.3, 0.5, 1.0]
CS = 400
dark_xy = (40, 360) # shaded forest, about 200 DN
mid_xy = (3800, 1300) # distant valley in haze, about 1400 DN
def crop(a, xy):
x, y = xy
return a[y:y + CS, x:x + CS]
# Errors against the 12-bit frame, per region. Per pixel: everything, including discarded noise.
# Averaged: after a Gaussian average (sigma 4 px, about 200 pixels) of both images - the error in
# local brightness that remains once noise is averaged away, i.e. lost scene information such as a
# faint, even brightening.
ref_avg = cv2.GaussianBlur(ref, (0, 0), 4)
size, dark_dec, errors = {}, {}, []
SOURCES = (("12-bit", c12, 12), ("8-bit", c8, 8), ("8-bit values, 12-bit file", c8in12, 12),
("camera RGB8", cam8, 8))
for name, img, bps in SOURCES:
for d in DIST:
kw = dict(lossless=True) if d == 0 else dict(distance=d)
data = imagecodecs.jpegxl_encode(np.ascontiguousarray(img), effort=3, bitspersample=bps, **kw)
size[(name, d)] = len(data) / 1e6
print(f"{name} d{d}: {len(data) / 1e6:.2f} MB", flush=True)
if name == "camera RGB8": # another capture: sizes only
continue
a = to12(imagecodecs.jpegxl_decode(data)).astype(np.float32)
if img is c8in12:
a -= 0.5
if name == "12-bit" and d == 0:
assert np.array_equal(a, ref), "12-bit lossless must be exact"
dark_dec[(name, d)] = crop(a, dark_xy).copy()
e_px = a - ref
e_av = cv2.GaussianBlur(a, (0, 0), 4) - ref_avg
row = dict(source=name, distance=d, size_mb=round(size[(name, d)], 2))
for rn, m in REGIONS.items():
row[rn] = dict(rms_px=round(float(np.sqrt((e_px[m] ** 2).mean())), 2),
rms_avg=round(float(np.sqrt((e_av[m] ** 2).mean())), 2))
errors.append(row)
del a, e_px, e_av
# Today's save: the camera's RGB8 at distance 0.8, effort 4.
data = imagecodecs.jpegxl_encode(cam8, effort=4, distance=0.8, bitspersample=8)
size[("camera RGB8 today e4", 0.8)] = len(data) / 1e6
dark_dec[("camera RGB8 today", 0.8)] = crop(to12(imagecodecs.jpegxl_decode(data)), dark_xy).copy()
# --- crops ---------------------------------------------------------------------------------
def upright(a):
return np.ascontiguousarray(np.rot90(a, 1)) # camera on its side: sky was on the right
def view(a12, stops):
return np.clip(a12 * 2.0 ** stops / 16, 0, 255).astype(np.uint8)
def grid(tiles, col_labels, row_labels, path, titles=None):
"""Tiles in rows; labels per column and row, or a title per tile."""
nr, nc = len(tiles), len(tiles[0])
fig, axs = plt.subplots(nr, nc, figsize=(nc * 3.0, nr * 3.25), squeeze=False)
for i in range(nr):
for j in range(nc):
axs[i][j].imshow(tiles[i][j], interpolation="nearest")
axs[i][j].set_xticks([]); axs[i][j].set_yticks([])
if titles:
axs[i][j].set_title(titles[i][j], fontsize=8)
elif i == 0:
axs[i][j].set_title(col_labels[j], fontsize=8)
if j == 0 and row_labels:
axs[i][j].set_ylabel(row_labels[i], fontsize=8)
fig.tight_layout(pad=0.4); fig.savefig(path, dpi=150); plt.close(fig)
d12, d8, dc = crop(ref, dark_xy), crop(to12(c8), dark_xy), crop(to12(cam8), dark_xy)
grid([[upright(view(a, s)) for a in (dc, d8, d12)] for s in (2, 4)],
["camera RGB8 (today, other capture)", "8-bit (same frame, 12-bit >> 4)", "12-bit"],
["+2 stops (×4)", "+4 stops (×16)"], os.path.join(out, "dark_crop.png"))
keys = [[("camera RGB8 today", 0.8), ("8-bit", 1.0)], [("8-bit values, 12-bit file", 1.0), ("12-bit", 1.0)]]
grid([[upright(view(dark_dec[k], 4)) for k in row] for row in keys], None, None,
os.path.join(out, "dark_compressed.png"),
titles=[["(a) camera RGB8 file, d0.8 e4 (today)", "(b) 8-bit file, d1.0"],
["(c) 8-bit values in a 12-bit file, d1.0", "(d) 12-bit file, d1.0"]])
mid_mean = crop(ref, mid_xy).mean(axis=(0, 1)) # per channel
grid([[upright(np.clip((crop(a, mid_xy) - mid_mean) * 4 / 16 + 128, 0, 255).astype(np.uint8))
for a in (to12(c8), ref)]],
["8-bit (same frame)", "12-bit"], ["contrast ×4 around the mean"], os.path.join(out, "mid_crop.png"))
# Overview: upright, quarter size, display gamma (sRGB) so the forest is visible.
small = cv2.resize(ref, (W // 4, H // 4), interpolation=cv2.INTER_AREA) / 4095
small = np.where(small <= 0.0031308, 12.92 * small, 1.055 * small ** (1 / 2.4) - 0.055)
ov = cv2.cvtColor((np.clip(small, 0, 1) * 255).astype(np.uint8), cv2.COLOR_RGB2BGR)
for (x, y), c in ((dark_xy, (255, 160, 0)), (mid_xy, (0, 0, 255))):
cv2.rectangle(ov, (x // 4, y // 4), ((x + CS) // 4, (y + CS) // 4), c, 2)
cv2.imwrite(os.path.join(out, "overview.jpg"), upright(ov), [cv2.IMWRITE_JPEG_QUALITY, 88])
def levels(a):
g = np.round(a[..., 1]).astype(int)
return int(len(np.unique(g)))
res = dict(
frame=dict(width=W, height=H, green_range_12bit=[int(c12[..., 1].min()), int(c12[..., 1].max())],
share_of_frame={k: round(float(m.mean()) * 100, 1) for k, m in REGIONS.items()}),
noise_calibration=round(CAL, 3),
noise={k: [[round(s), round(n, 1)] for s, n in v] for k, v in curves.items()},
dark_crop=dict(x=dark_xy[0], y=dark_xy[1], size=CS, mean_green_12bit=round(float(d12[..., 1].mean()), 1),
green_range_12bit=[int(d12[..., 1].min()), int(d12[..., 1].max())],
distinct_green_levels={"12-bit": levels(d12), "8-bit same frame": levels(d8),
"camera RGB8": levels(dc)}),
mid_crop=dict(x=mid_xy[0], y=mid_xy[1], size=CS,
mean_green_12bit=round(float(crop(ref, mid_xy)[..., 1].mean()), 1)),
sizes_mb={f"{n} d{d}": round(v, 2) for (n, d), v in size.items()},
errors=errors,
)
json.dump(res, open(os.path.join(out, "results.json"), "w"), indent=1)
print(json.dumps(res, indent=1))