562 lines
36 KiB
Markdown
562 lines
36 KiB
Markdown
# 12-bit raw capture pipeline — final report
|
||
|
||
**Question:** should the towers stop asking the camera for 8-bit colour (RGB8) and take raw
|
||
12-bit Bayer (`BayerRG12p`) instead, with the LattePanda converting it to colour and saving 12-bit
|
||
JPEG XL? The aim is a higher frame rate, so auto-exposure and auto white balance settle faster,
|
||
and more tonal detail for the smoke-detection algorithms. Can the IOTA (tower 121) and the
|
||
3 Delta (tower 111) carry the work, how long does each step take, and how much space do the
|
||
images need?
|
||
|
||
**Campaign:** 2–5 October 2026. Camera tests on tower 121 (2 Oct) and tower 111 (4 Oct) **with the
|
||
same camera** (Alvium 1800 U-1620c, 5328×3040), moved from 121 to 111 and looking at the same
|
||
scene, so the two towers differ only in their computer. Processing tests repeated on **one
|
||
identical frame** on both towers (4 Oct). Earlier blurred-scene measurements:
|
||
[camera-capture-measurements.md](camera-capture-measurements.md). What to build from this:
|
||
[raw12-pipeline-implementation-plan.md](raw12-pipeline-implementation-plan.md).
|
||
|
||
## Conclusions
|
||
|
||
| Topic | Finding | Evidence |
|
||
|---|---|---|
|
||
| Frame rate | `BayerRG12p` runs at **9.86 fps**, 2× RGB8's 4.95 fps, at the 250 MB/s cap; `BayerRG8` 14.75 fps. Unpacked `BayerRG12` only 7.4 fps: use the packed format. | [streaming](#frame-rate-and-streaming) |
|
||
| Link | No lost or incomplete frames in any format on either tower, also while saving 12-bit flat out. | [streaming](#frame-rate-and-streaming) |
|
||
| Auto-exposure | From **under-exposure** (1/16, brightness ~11 of 255) or **over-exposure** (×16, 97–100% blown out), settling takes **~25–35 frames**, so time scales with 1/fps: raw 8-bit 1.8–2.7 s, **12-bit 2.2–6.6 s (typically ~3 s)**, RGB8 4.6–6.6 s. | [method](#method), [auto-exposure](#auto-exposure) |
|
||
| Which frame to keep | **"Brightness in the camera's target band and steady for 3 frames"**: right in 21 of 24 steps. "Exposure and gain unchanged" was never right. | [settle rules](#which-frame-to-keep) |
|
||
| Auto white balance | Recovers in **2–4 frames** (0.2–0.8 s) at every rate; stable within ±1–3% on a still scene. Exposure, not white balance, sets the wait. | [white balance](#auto-white-balance) |
|
||
| Processing cost | Same frame on both computers: unpack + colour conversion **38 ms (IOTA) / 50 ms (Delta)**; JPEG XL encode 480 / 765 ms. **A 12-bit save (518 / 820 ms) costs a little less than today's RGB8 save (567 / 872 ms).** | [stages](#where-the-time-goes) |
|
||
| IOTA vs 3 Delta | **The Delta takes 1.6× as long** on the identical frame (820 vs 518 ms for a 12-bit save); it saves 1.19 frames/s with one worker, 1.53 with two (IOTA 1.79 / 2.15). | [stages](#where-the-time-goes) |
|
||
| Pace | The camera side sets the pace: a waypoint needs ~1.5–5 s (move + exposure), a 12-bit save 0.56 s (IOTA) or 0.65 s (Delta, two workers). On the shared frame the IOTA keeps up with one frame every **0.7 s** with one worker; the Delta needs **two workers** for that. | [pace](#overall-pace-how-fast-can-a-tower-capture), [pile-up](#keeping-up-with-the-scan) |
|
||
| Heat | One frame every 2 s: IOTA **53 °C / 5.1 W**, Delta **66 °C / 5.7 W**. Flat out: ~80 °C on both; the IOTA slowed 16% over 10 min, the Delta held steady at its 8 W power limit. | [heat](#heat-and-power) |
|
||
| Storage | 12-bit colour at distance 1.0: **2.7 MB/frame**, less than today's 3.7–4.0 MB. Lossless 12-bit: 44–47 MB as colour, **17–18 MB as raw planes**. | [quality](#quality-setting-size-and-quality) |
|
||
| **Value of 12 bits** | Sensor noise in this sunlit scene is **2–6 steps of 8-bit even at 0 dB gain**, so 8-bit already holds what the sensor delivers in mid and bright tones. 12-bit adds information only below about **300 of 4095** (deep shadow), which daylight scenes barely have and dusk or night scenes do. | [bit depth](#what-12-bit-adds) |
|
||
|
||
### Recommendation
|
||
|
||
1. **Take raw from the camera** (`BayerRG12p`, 250 MB/s) and convert to colour on the tower,
|
||
edge-aware. It doubles the frame rate and roughly halves the settle time, at no extra CPU,
|
||
heat or storage cost compared with today, on either computer.
|
||
2. **Save 12-bit colour JPEG XL at distance 1.0, effort 3, 4 threads.** 2.7 MB per frame.
|
||
3. **Workers per computer:** one worker on the IOTA; two workers × 2 threads on the Delta, which
|
||
then keeps up with a frame every 1.0 s. With the measured settle times (~2–3 s per waypoint),
|
||
the scan pace stays below either limit. The save queue still needs a bound.
|
||
4. **Keep the frame by "target band and steady for 3 frames",** with a time limit.
|
||
5. **12-bit vs 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` keeps the deep shadows at dusk and
|
||
night. The pixel format lives in the camera profile, so the code should accept both, and
|
||
`standard` and `night` can each use the right one.
|
||
6. **Store raw 12-bit planes losslessly (17–18 MB) only if the detection pipeline wants exact sensor
|
||
values**; it is 6× the storage.
|
||
|
||
## Setup
|
||
|
||
| | Tower 121 | Tower 111 |
|
||
|---|---|---|
|
||
| Computer | LattePanda IOTA, Intel N150, 4 cores, up to 3.6 GHz, 7 GB | LattePanda 3 Delta, Celeron N5105, 4 cores, up to 2.9 GHz, 7.6 GB |
|
||
| Storage write | eMMC, 218 MB/s | eMMC, 76 MB/s |
|
||
| Libraries | libjxl 0.11.1, OpenCV 4.10 | libjxl 0.11.0, OpenCV 4.5.4 |
|
||
| Date, light | 2 Oct, 15:01–16:50, sun | 4 Oct, 16:19–18:19, sun then evening |
|
||
| Camera | Alvium 1800 U-1620c `DEV_1AB22C0B7D4E`, 5328×3040, link cap 250 MB/s | same camera |
|
||
| Exposure | capped at 20 ms (the `standard` profile), so ~15 dB gain in this scene | same |
|
||
|
||
The scene, through the camera: spruce, birch trunks, leaves, a building, ~13% blown-out sky.
|
||
|
||

|
||
|
||
The tower program was stopped during every test, and both cameras went back to their `standard`
|
||
profiles afterwards.
|
||
|
||
## Method
|
||
|
||
Everything ran on the towers themselves, with the tower program stopped (the camera accepts one
|
||
program at a time, and the CPU figures should have a single cause). The test programs are in
|
||
[tools/camera-bench](../tools/camera-bench/README.md); the raw results, one JSON line per
|
||
measurement, are linked at the end.
|
||
|
||
### How the camera is driven
|
||
|
||
`camera_bench.py` talks to the camera through Allied Vision's Python SDK (vmbpy), the same
|
||
GenICam interface the tower program uses through C++. Every setting change is a **GenICam feature
|
||
write** (`feature.set(value)`), sent over the USB control channel **while the camera keeps
|
||
streaming**. `ExposureAuto`, `GainAuto` and `BalanceWhiteAuto` are switches with the values `Off`,
|
||
`Once` and `Continuous`. Setting one to `Off` freezes the value the automatic control had reached;
|
||
`ExposureTime`, `Gain` and `BalanceRatio` can then be written directly and take effect from the next
|
||
frame the camera exposes. No profile, user set or file on the camera is changed; afterwards the
|
||
camera is put back by reloading its `standard.xml` profile, exactly as the tower program does.
|
||
|
||
Common settings for every camera test:
|
||
|
||
| Setting | Value | Why |
|
||
|---|---|---|
|
||
| Resolution | 5328×3040, binning 1 | full resolution, as decided |
|
||
| `DeviceLinkThroughputLimit` | 250 MB/s | the agreed conservative link cap |
|
||
| `AcquisitionFrameRate` | the camera's maximum for the format | 9.86 fps 12-bit, 14.75 fps 8-bit raw, 4.95 fps RGB8 |
|
||
| `ExposureAutoMax` | 20 ms | as in the `standard` profile; keeps long exposures from limiting the frame rate |
|
||
| `IntensityAutoPrecedence` | `MinimizeNoise` | as the camera was left (favours exposure over gain; with exposure capped at 20 ms, the camera then uses gain) |
|
||
| `IntensityControllerTarget` / `Tolerance` / `Rate` | 50% / 5% / 100 | camera defaults; rate 100 is the fastest |
|
||
| `GainAutoMax` | as left in the camera (≥ 43.5 dB) | the bright steps reached 43.5 dB |
|
||
| Chunk data | on: `ExposureTime`, `Gain`, `BalanceRatioRed`, `BalanceRatioBlue`, `Timestamp` | each frame carries the exact settings it was exposed with |
|
||
| Buffers | 5 | as production |
|
||
|
||
**Per-frame record.** For every frame the script keeps the host arrival time, the camera's frame
|
||
number, complete or not, the chunk data above, and the frame's **brightness and blown-out share**,
|
||
sampled from the image buffer itself: every 997th byte for RGB8 and 8-bit raw (mixed colours), the
|
||
high byte of every 331st pixel pair for `BayerRG12p` (green and blue sites), each scaled to 0–255.
|
||
"Blown out" means a sample ≥ 250 of 255.
|
||
|
||
### Frame rate and streaming
|
||
|
||
For each format: set it, read the camera's maximum frame rate (the camera computes it from the
|
||
link cap and frame size), stream 60 s at that rate, and count frames received, incomplete frames
|
||
(the SDK's frame status) and lost frames (gaps in the camera's frame counter). Once more for
|
||
`BayerRG12p` while the tower saved 12-bit images flat out in parallel.
|
||
|
||
### Auto-exposure step test
|
||
|
||
One run, per format and direction (`camera_bench.py ae`):
|
||
|
||
| Phase | Duration | Exactly what is done |
|
||
|---|---|---|
|
||
| 1. Settle | 8 s | Stream with `ExposureAuto = Continuous`, `GainAuto = Continuous`. "Before" = median exposure and gain of the last 5 frames (chunk data). The brightness the camera steers is exposure × linear gain: E₀ = exposure × 10^(gain/20). |
|
||
| 2. Force | 2 s | `ExposureAuto = Off`, `GainAuto = Off` (freezes them). Then write a new exposure and gain. **Dark:** `ExposureTime` = max(`ExposureAutoMin`, E₀/16), `Gain` = 0 dB. **Bright:** `ExposureTime` = min(20 ms, 16·E₀), and the rest as gain: `Gain` = 20·log10(16·E₀ / exposure), up to `GainAutoMax`. Streaming continues; the camera delivers 10–30 frames at the forced settings. |
|
||
| 3. Release | 15 s | Time zero is taken, then `ExposureAuto = Continuous` and `GainAuto = Continuous` are written. Every frame for 15 s is recorded. |
|
||
|
||
With exposure at its 20 ms cap before the step, the forced values came out as:
|
||
|
||
| | Before (settled) | Forced "dark" (start) | Forced "bright" (start) |
|
||
|---|---|---|---|
|
||
| Exposure | 20 ms (at its cap) | 7.4–11.5 ms | 20 ms |
|
||
| Gain | 15.4–19.5 dB | 0 dB | 39.6–43.5 dB (before + 24.1 dB) |
|
||
| Brightness (0–255), first frame after release | 117–122 | **9–13: under-exposed** | **254–255: over-exposed** |
|
||
| Blown-out share | 10–15% (sky) | 0% | **97–100%** |
|
||
|
||
So settling was measured from **both sides, under-exposure and over-exposure**. The bright start is
|
||
fully saturated: the camera cannot see how far over it is and can only step down until detail
|
||
reappears.
|
||
|
||
Each format × direction was run twice: 12 runs per tower, 24 in all. **"Settled"** is judged on the
|
||
image: the time from release after which the frame's brightness stays within ±10% of where it
|
||
finally settles (median brightness of the run's last 2 s), with the number of frames that took.
|
||
The first 1–5 frames after release still carry the forced values (frames already in the
|
||
camera-to-host pipeline); they count towards the settle time.
|
||
|
||
**Keep rules** were then scored offline on the same 24 per-frame logs: for each rule, the first frame
|
||
at which it would have kept a picture, and whether that frame's brightness was within 10% of the
|
||
final level ("correct") or not ("early"). The camera's target band is 50% ± 5% of full scale, i.e.
|
||
114.8–140.3 on the 0–255 scale used here.
|
||
|
||
### Auto white balance step test
|
||
|
||
One run (`camera_bench.py awb`), per format and direction:
|
||
|
||
| Phase | Duration | Exactly what is done |
|
||
|---|---|---|
|
||
| 1. Exposure | 6 s | Auto-exposure on, settle; then `ExposureAuto = Off`, `GainAuto = Off`, and `ExposureTime`/`Gain` written to the median of the last 5 frames, so only white balance moves from here on. `BalanceWhiteAutoRate = 100` (fastest). |
|
||
| 2. Reference | 8 s | `BalanceWhiteAuto = Continuous`. The reference = median red and blue ratios of the frames in the last 3 s (the camera's own answer for this scene: red 2.34–2.46, blue 1.86–1.97). |
|
||
| 3. Force | 2 s | `BalanceWhiteAuto = Off`; `BalanceRatioSelector = Red`, `BalanceRatio` = reference × factor; the same for Blue (green is the camera's fixed reference). **×0.5**: red ≈ 1.2, blue ≈ 0.95 (image too green); **×2**: red ≈ 4.8, blue ≈ 3.8 (too magenta). |
|
||
| 4. Release | 15 s | Time zero, then `BalanceWhiteAuto = Continuous`; every frame's ratios recorded (chunk data). |
|
||
|
||
"Settled" = both ratios within ±5% of the reference and staying there. Each case twice; also the
|
||
spread of the reference over phase 2 on the still scene.
|
||
|
||
### Processing tests
|
||
|
||
`raw12_bench.cpp`, built on each tower against its own libjxl and OpenCV, runs the steps the tower
|
||
program would run for one saved frame and **times each with a monotonic clock**:
|
||
|
||
| Step | What it does |
|
||
|---|---|
|
||
| Unpack | `BayerRG12p` (two 12-bit pixels in three bytes) → one 16-bit word per pixel |
|
||
| Colour conversion | OpenCV `cvtColor`, `COLOR_BayerBG2RGB` (bilinear) or `…_EA` (edge-aware), 8- or 16-bit; the camera's `BayerRG` is OpenCV's `BayerBG` (checked against the camera's own RGB8) |
|
||
| JPEG XL encode | libjxl into memory; threads via libjxl's thread pool; effort and distance as stated; 12-bit input as 16-bit words with the bit depth taken from the file header; lossless with `uses_original_profile` set |
|
||
| File write | `fopen`/`fwrite`/`fflush`/`fclose` to the tower's eMMC |
|
||
|
||
Each figure is the median of 3 runs (2 in the quality sweep). Before every run on every tower the
|
||
program checks that its 8-bit encoder produces **byte for byte** the file production's encoder
|
||
produces, that 12-bit lossless decodes to **exactly** the input, and that its colour order matches
|
||
the camera's.
|
||
|
||
**Input frames.**
|
||
- **Shared frame, identical on both towers (the tower comparison uses this):** tower 121's frame of
|
||
2 Oct (15:07, 20 ms, ~15 dB), rebuilt bit-exactly from its lossless 12-bit file and repacked to
|
||
`BayerRG12p`; its 8-bit raw is the 12-bit value shifted down 4 bits; its "RGB8" is the edge-aware
|
||
colour conversion of the 12-bit frame shifted to 8 bits (the camera's own RGB8 of that instant
|
||
does not exist). Copied to both towers; MD5 checksums identical.
|
||
- **Synthetic frame, identical on both towers:** a "dead leaves" texture (the camera texture chart
|
||
of ISO 19567-2) with sensor noise, same generator and seed: sharper than any real scene, the worst
|
||
case for the encoder.
|
||
- Each tower's own camera capture from its test day.
|
||
|
||
**Quality.** Every 12-bit result is decoded and compared with the exact lossless 12-bit colour image
|
||
of the same frame (mosaic results are converted to colour the same way first): PSNR on the 12-bit
|
||
scale, and SSIM on luma, which follows structure and is less fooled by noise.
|
||
|
||
**Load.** *Back to back:* workers save the same frame from memory continuously for 60 s.
|
||
*Paced:* a producer queues one frame every 2.0, 1.5, 1.0, 0.7 or 0.5 s for 2 minutes, like a scan
|
||
keeping one frame per waypoint; each job runs every step above; recorded: time from queueing to file
|
||
written, frames still queued at the end, peak memory. *Heat:* 10 minutes flat out and 10 minutes at
|
||
one frame every 2 s, each after a 5-minute cool-down, with once a second: CPU package temperature
|
||
(`x86_pkg_temp`), average core clock, CPU busy share (`/proc/stat`) and package power (Intel RAPL
|
||
energy counter, read through a root helper).
|
||
|
||
### Noise test
|
||
|
||
To see whether the 12th bit carries information, sensor noise was measured on the raw 12-bit
|
||
values: the green sites split into 24×24-pixel patches; in each, noise = the robust spread (median
|
||
absolute deviation) of differences between horizontal neighbours ÷ √2; patches binned by brightness;
|
||
the quietest 10% in each bin taken as the noise floor (texture only adds to it). Once for the
|
||
~15 dB frame and once for a frame taken at **0 dB gain** (124 ms exposure, 4 Oct 16:19, exposure
|
||
cap temporarily raised to 200 ms).
|
||
|
||
### Campaign log
|
||
|
||
| When | Tower | What | Duration | Notes |
|
||
|---|---|---|---|---|
|
||
| 2 Oct 15:01–15:22 | 121 | camera tests | 21 min | sunny |
|
||
| 2 Oct 15:22–16:50 | 121 | processing, pile-up, heat on its own frame and the synthetic frame | 88 min | |
|
||
| 2 Oct 19:01 | 121 | attempt at a 0 dB frame | – | too dark (24 dB needed); discarded |
|
||
| 4 Oct 16:19 | 111 | 0 dB frame (camera moved from 121) | 2 min | |
|
||
| 4 Oct 16:20–16:41 | 111 | camera tests | 21 min | 111's own 1240c camera connected but idle |
|
||
| 4 Oct 16:41–18:19 | 111 | processing, pile-up, heat | 98 min | |
|
||
| 4 Oct 23:03–23:32 | 121 + 111 | processing on the shared frame, both towers at once | 29 min | no camera needed |
|
||
|
||
## Frame rate and streaming
|
||
|
||

|
||
|
||
Same camera on both towers, so identical maximum rates. 60 s at the maximum:
|
||
|
||
| Format | Frame | Max fps | 121: complete / incomplete / lost | 111: complete / incomplete / lost |
|
||
|---|---|---|---|---|
|
||
| BayerRG12p | 24.3 MB | 9.86 | 593 / 0 / 0 | 592 / 0 / 0 |
|
||
| BayerRG12 (unpacked) | 32.4 MB | 7.41 | 445 / 0 / 0 | 445 / 0 / 0 |
|
||
| BayerRG10p | 20.2 MB | 11.81 | 711 / 0 / 0 | 709 / 0 / 0 |
|
||
| BayerRG8 | 16.2 MB | 14.75 | 887 / 0 / 0 | 887 / 0 / 0 |
|
||
| RGB8 (today) | 48.6 MB | 4.95 | 298 / 0 / 0 | 297 / 0 / 0 |
|
||
| **BayerRG12p while saving 12-bit flat out** | 24.3 MB | 9.86 | 591 / 0 / 0 | 592 / 0 / 0 |
|
||
|
||
On tower 111 the camera shares the USB 3 controller with the 5G modem, a USB network adapter and
|
||
the tower's second camera (idle during the test).
|
||
|
||
## Auto-exposure
|
||
|
||

|
||

|
||
|
||
With exposure at its 20 ms cap, auto-exposure works through gain, 1–2 dB per frame, and sometimes
|
||
pauses mid-way (the flat stretches, up to 2.8 s while still far too bright).
|
||
|
||
Time until brightness stays within ±10% of where it settles (two runs each):
|
||
|
||
| Format | fps | Step | Tower 121 | Tower 111 |
|
||
|---|---|---|---|---|
|
||
| BayerRG8 | 14.75 | dark ×1/16 | 1.94 s (28 frames), 2.21 s (32) | 1.79 s (26), 1.87 s (27) |
|
||
| BayerRG8 | 14.75 | bright ×16 | 2.69 s (39), 2.55 s (37) | 2.00 s (29), 1.86 s (27) |
|
||
| **BayerRG12p** | 9.86 | dark ×1/16 | 2.90 s (28), 3.00 s (29) | 6.64 s (65), 4.51 s (44) |
|
||
| **BayerRG12p** | 9.86 | bright ×16 | 6.45 s (63), 2.69 s (26) | 2.18 s (21), 5.73 s (56) |
|
||
| RGB8 (today) | 4.95 | dark ×1/16 | 4.56 s (22), 5.17 s (25) | 5.97 s (29), 5.77 s (28) |
|
||
| RGB8 (today) | 4.95 | bright ×16 | 5.17 s (25), 5.17 s (25) | 6.57 s (32), 4.96 s (24) |
|
||
|
||
Typically ~25–35 frames, so **settling time scales with 1/fps**. The longer 12-bit runs (44–65
|
||
frames) are the mid-way pauses; they happened at every rate but hit the 12-bit runs more often here.
|
||
|
||
### Which frame to keep
|
||
|
||
Each rule applied to the per-frame log of all 24 steps. "Correct" means the frame it picks is within
|
||
10% of the brightness the camera finally settles at.
|
||
|
||
| Rule | 121 correct / early | 111 correct / early | Median time when correct (121 / 111) |
|
||
|---|---|---|---|
|
||
| Exposure and gain unchanged for 3 frames | 0 / 12 | 0 / 12 | – |
|
||
| Brightness in the camera's target band (target 50% ± 5%), 2 frames | 6 / 6 | 6 / 6 | 3.30 / 6.10 s |
|
||
| Target band 2 frames and exposure/gain unchanged 2 frames | 6 / 6 | 7 / 5 | 3.35 / 5.02 s |
|
||
| Target band 3 frames | 8 / 4 | 8 / 4 | 3.40 / 4.84 s |
|
||
| **Target band and brightness steady (≤2% change) over 3 frames** | **11 / 1** | **10 / 2** | 3.61 / 5.45 s |
|
||
|
||
Coming down from too bright, brightness enters the band about 15% above where it settles, so "in
|
||
band" alone fires early; requiring it to have stopped changing fixes most of that.
|
||
|
||
## Auto white balance
|
||
|
||
The profiles fix white balance today (red 2.87, blue 2.0). With `BalanceWhiteAuto = Continuous`, the
|
||
camera settled this scene at red ≈ 2.36–2.46, blue ≈ 1.87–1.97.
|
||
|
||

|
||

|
||
|
||
| Format | fps | Within ±5% and stays, 121 | Within ±5% and stays, 111 |
|
||
|---|---|---|---|
|
||
| BayerRG12p | 9.86 | 0.21–0.32 s (2–3 frames); once 1.33 s | 0.22–0.41 s (2–4) |
|
||
| RGB8 | 4.95 | 0.57–0.77 s (2–3) | 0.55–0.76 s (2–3) |
|
||
| BayerRG8 | 14.75 | 0.26–0.27 s (3) | 0.19–0.26 s (2–3) |
|
||
|
||
On a still scene the auto ratios held within ±0.8–3.1%.
|
||
|
||
## Where the time goes
|
||
|
||
### Same frame on both computers
|
||
|
||
The fairest comparison: the **identical shared frame** (checksums verified on both towers), every
|
||
step timed on its own, 4 threads. Colour paths at distance 1.0 and effort 3; today's RGB8 path at
|
||
its production settings (distance 0.8, effort 4).
|
||
|
||

|
||
|
||
| Step | IOTA (121) | 3 Delta (111) | Delta / IOTA |
|
||
|---|---|---|---|
|
||
| Unpack 12-bit packed → 16-bit | 10 ms | 17 ms | 1.7× |
|
||
| Colour conversion, 8-bit raw, bilinear / edge-aware | 8 / 9 ms | 11 / 12 ms | 1.3× |
|
||
| Colour conversion, 12-bit raw, bilinear / edge-aware | 17 / 28 ms | 22 / 33 ms | 1.2× |
|
||
| JPEG XL encode, 12-bit colour, d1.0 e3 | 480 ms | 765 ms | 1.6× |
|
||
| JPEG XL encode, today's RGB8, d0.8 e4 | 566 ms | 867 ms | 1.5× |
|
||
| File write, 2.7 MB colour file | 1 ms | 4 ms | |
|
||
| File write, 17–21 MB lossless raw file | 7–8 ms | 162–173 ms | (storage: 218 vs 76 MB/s) |
|
||
|
||
Per saved frame, all steps together, and the file each produces (sizes are identical on both
|
||
computers, as they must be for the same input):
|
||
|
||
| Path | IOTA total | Delta total | File |
|
||
|---|---|---|---|
|
||
| Today: RGB8 → JPEG XL, d0.8 e4 | 567 ms | 872 ms | 3.27 MB |
|
||
| Raw 8 → colour → JPEG XL, d1.0 e3 | 468 ms | 741 ms | 2.66 MB |
|
||
| Raw 12 → colour (bilinear) → JPEG XL, d1.0 e3 | 476 ms | 778 ms | 2.53 MB |
|
||
| **Raw 12 → colour (edge-aware) → JPEG XL, d1.0 e3** | **518 ms** | **820 ms** | **2.65 MB** |
|
||
| Raw 12 planes, lossless (e1) | 91 ms | 297 ms | 17.65 MB |
|
||
| Raw 12 mosaic, lossless (e1) | 87 ms | 303 ms | 20.94 MB |
|
||
|
||
Unpacking and colour conversion together cost **38 ms on the IOTA and 50 ms on the Delta**, 7% of
|
||
the save. The JPEG XL encode is 90% of it.
|
||
|
||
The "RGB8" input here is the edge-aware colour conversion of the same frame (the camera's own RGB8
|
||
of that instant does not exist), so its file matches the raw 8-bit path's. The camera's real RGB8
|
||
files were a little larger in the per-tower runs below (3.7–4.0 MB).
|
||
|
||
### Size and time per quality setting, same frame
|
||
|
||

|
||
|
||
| Distance | File (12-bit colour) | IOTA | Delta | File (raw 12 planes) |
|
||
|---|---|---|---|---|
|
||
| lossless | 44.8 MB | 1,225 ms | 2,906 ms | 16.8 MB (455 / 860 ms) |
|
||
| 0.3 | 7.19 MB | 742 ms | 1,197 ms | 7.31 MB |
|
||
| 0.5 | 5.15 MB | 678 ms | 1,033 ms | 5.99 MB |
|
||
| 0.8 | 3.16 MB | 570 ms | 848 ms | 4.32 MB |
|
||
| **1.0** | **2.65 MB** | **558 ms** | **847 ms** | 3.78 MB |
|
||
| 1.5 | 1.63 MB | 515 ms | 751 ms | 2.44 MB |
|
||
| 2.0 | 1.32 MB | 558 ms | 738 ms | 1.96 MB |
|
||
|
||
(Effort 3, 4 threads, file write included. A separate pass of 2 runs per point, so it differs from
|
||
the stage table above by run-to-run variation, up to ~40 ms.)
|
||
|
||
### Each tower's own capture
|
||
|
||
Same camera and scene, different day and light; consistent with the shared-frame result.
|
||
|
||

|
||

|
||
|
||
| Path (effort 3, 4 threads, d1.0) | IOTA, own frame / synthetic | Delta, own frame / synthetic |
|
||
|---|---|---|
|
||
| Today: camera RGB8 | 463 / 652 ms | 788 / 1,071 ms |
|
||
| Raw 12 → colour (edge-aware) | 569 / 727 ms | 849 / 1,088 ms |
|
||
| Raw 12 planes, lossless (e1) | 90 / 96 ms | 276 / 298 ms |
|
||
|
||
### Effort and threads
|
||
|
||

|
||

|
||
|
||
- Effort 1–4 are within ~15% of each other; **effort 3 gives the smallest files**.
|
||
- On the IOTA, **3 threads is slower than 2** at every effort (e.g. 761 / 582 / 672 / 512 ms for
|
||
1–4 threads at effort 1); the Delta scales normally (1,440 / 985 / 886 / 802 ms). 4 is fastest on
|
||
both computers.
|
||
|
||
## Quality setting: size and quality
|
||
|
||

|
||
|
||
Against the exact lossless 12-bit colour image of the same frame (real plant frame, ~15 dB gain;
|
||
the two towers agree within 0.3 dB):
|
||
|
||
| Saved as | File 121 / 111 | PSNR | SSIM |
|
||
|---|---|---|---|
|
||
| 12-bit colour, distance 0.3 | 7.2 / 7.4 MB | 40.3 dB | 0.994 |
|
||
| 12-bit colour, distance 0.5 | 5.1 / 5.3 MB | 38.8 dB | 0.989 |
|
||
| 12-bit colour, distance 0.8 | 3.2 / 3.3 MB | 37.1 dB | 0.978 |
|
||
| **12-bit colour, distance 1.0** | **2.6 / 2.7 MB** | 36.6 dB | 0.973 |
|
||
| 12-bit colour, distance 1.5 | 1.6 / 1.7 MB | 35.4 dB | 0.960 |
|
||
| 12-bit colour, distance 2.0 | 1.3 / 1.3 MB | 34.8 dB | 0.953 |
|
||
| 12-bit colour, bilinear, distance 1.0 | 2.5 / 2.6 MB | 36.6 dB | 0.968 |
|
||
| 12-bit raw planes, lossy 0.5 | 6.0 / 6.0 MB | **47.2 dB** | 0.990 |
|
||
| 12-bit raw planes, lossless | 17.6 / 17.7 MB | exact | 1 |
|
||
| 12-bit raw mosaic, lossless | 20.9 / 19.0 MB | exact | 1 |
|
||
| 12-bit colour, lossless | 47.4 / 46.3 MB | exact | 1 |
|
||
|
||
- **Colour JPEG XL is tuned to the eye:** it drops sensor noise and fine colour, so PSNR is
|
||
moderate while SSIM (structure) stays high.
|
||
- **Raw planes keep the sensor values much closer** at the same size (47 vs 39 dB, equal SSIM).
|
||
They suit a detector that wants raw values and can convert to colour itself. Lossless, they take
|
||
a third of the space of lossless colour.
|
||
|
||
## Example images
|
||
|
||
Every compression type, from tower 121's frame: a 100% crop of spruce needles over bright leaves;
|
||
its difference from lossless (×8, black = identical); and a darker crop brightened 2 stops.
|
||
Full-size `.jxl` files are kept outside git in `tools/camera-bench/examples-2026-10-02-tower121/`,
|
||
`examples-2026-10-04-tower111/` and `examples-2026-10-04-lowgain/` (~115 MB each).
|
||
Crop positions: 
|
||
|
||
| Saved as | File, time (121) | Detail (100%) | Difference ×8 | Shadow, +2 stops |
|
||
|---|---|---|---|---|
|
||
| Camera RGB8, d0.8 e4 (today; another capture) | 3.8 MB, 0.62 s |  | – |  |
|
||
| Raw 8 → colour, d1.0 (another capture) | 2.7 MB, 0.45 s |  | – |  |
|
||
| 12-bit colour, lossless (reference) | 47.4 MB, 0.23 s |  |  |  |
|
||
| 12-bit colour, d0.3 | 7.2 MB, 0.77 s |  |  |  |
|
||
| 12-bit colour, d0.5 | 5.1 MB, 0.69 s |  |  |  |
|
||
| 12-bit colour, d0.8 | 3.2 MB, 0.58 s |  |  |  |
|
||
| **12-bit colour, d1.0** | 2.6 MB, 0.53 s |  |  |  |
|
||
| 12-bit colour, d1.5 | 1.6 MB, 0.46 s |  |  |  |
|
||
| 12-bit colour, d2.0 | 1.3 MB, 0.42 s |  |  |  |
|
||
| 12-bit colour, bilinear, d1.0 | 2.5 MB, 0.48 s |  |  |  |
|
||
| 12-bit raw planes, d0.5 | 6.0 MB, 0.54 s |  |  |  |
|
||
| 12-bit raw planes, lossless | 17.6 MB, 0.09 s |  |  |  |
|
||
| 12-bit raw mosaic, lossless | 20.9 MB, 0.09 s |  |  |  |
|
||
|
||
The two 8-bit rows are separate captures seconds apart (branches move), so they have no difference
|
||
map. Times are single runs at 4 threads. Tower 111's examples (same camera and scene) and the
|
||
0 dB examples are kept as full-size `.jxl` only; `tools/camera-bench/make_figures.py` makes the same
|
||
crops from them.
|
||
|
||
## What 12-bit adds
|
||
|
||
To see whether the extra 4 bits carry information or noise, a frame was also taken at **0 dB gain**
|
||
(124 ms exposure, 4 Oct). The noise floor, from the quietest patches at each brightness:
|
||
|
||

|
||
|
||
| Signal (12-bit, green) | Noise at 0 dB | Noise at ~15 dB | In 8-bit steps (0 dB / 15 dB) |
|
||
|---|---|---|---|
|
||
| ~700 (darkest in this scene) | 26 | 42 | 1.6 / 2.6 |
|
||
| ~1,500 | 35 | 58 | 2.2 / 3.6 |
|
||
| ~3,000 | 37 | 80 | 2.3 / 5.0 |
|
||
|
||
Rounding to 8 bits adds an error of about 4.6 (12-bit units): small next to this noise. **In
|
||
daylight, 8-bit already holds what the sensor delivers.** By extrapolation, 12-bit starts to carry
|
||
information below about 300 (0 dB) or 100 (15 dB) of 4095, i.e. in deep shadow: dusk, night, the
|
||
dark side of a ridge. That is where a 12-bit night profile would pay off. Not measured at night.
|
||
|
||
Same frame, a darker crop brightened 2 stops, 8-bit (left, the 12-bit value >> 4) vs 12-bit (right),
|
||
at ~15 dB and at 0 dB. 3,138 vs 227 distinct levels at 15 dB, yet no visible difference:
|
||
|
||

|
||

|
||
|
||
At 0 dB the same settings give **25–30% smaller files and ~3 dB higher PSNR** (less noise to
|
||
encode): 12-bit colour at distance 1.0 is 1.9 MB, PSNR 40.0 dB, SSIM 0.987.
|
||
|
||
## Overall pace: how fast can a tower capture?
|
||
|
||
A waypoint goes through these steps; saving runs in the background, overlapping the next move:
|
||
|
||
| Step | 12-bit raw (proposed) | Raw 8-bit | RGB8 (today) | Source |
|
||
|---|---|---|---|---|
|
||
| Camera delivers a frame every | 0.10 s (9.86 fps) | 0.07 s (14.75 fps) | 0.20 s (4.95 fps) | measured |
|
||
| Slew to the next waypoint | ~1–1.5 s (6° step at 5–8°/s) | same | same | estimate from the scan speed; not measured here |
|
||
| Mechanical settle after standstill | ~0.2 s | same | same | to be measured in the field |
|
||
| Exposure settle after a **large** change (×16) | ~3 s (2.2–6.6) | ~2 s (1.8–2.7) | ~5 s (4.6–6.6) | measured, 24 steps per tower |
|
||
| Exposure settle after a small change (neighbouring waypoints) | well under the above | | | not measured |
|
||
| **Save, one worker × 4 threads (IOTA / Delta)** | **0.56 / 0.84 s** | 0.52 / 0.85 s | 0.59 / 0.88 s | measured, shared frame, back to back |
|
||
| **Save, two workers × 2 threads (IOTA / Delta)** | **0.47 / 0.65 s** | 0.42 / 0.60 s | 0.43 / 0.64 s | measured, shared frame, back to back |
|
||
|
||

|
||
|
||
- **The camera side sets the pace.** A waypoint needs roughly 1.5–5 s (move, then exposure),
|
||
while a 12-bit save takes 0.56 s on the IOTA and 0.65 s on the Delta with two workers. Saving
|
||
uses a fraction of the time between waypoints on either computer.
|
||
- **Saving becomes the limit only below about one waypoint per 0.6–0.85 s**, which a real scan does
|
||
not reach. On the shared frame, the IOTA kept up with a frame every 0.7 s with one worker; the
|
||
Delta needed two workers for 0.7 s (with one it fell 27 frames behind in 2 minutes).
|
||
- Settling after small changes, the common case between neighbouring waypoints, still needs a field
|
||
measurement; it is what decides the real scan pace.
|
||
|
||
## Keeping up with the scan
|
||
|
||

|
||
|
||
Synthetic detailed frame (worst case). *ok* = no backlog after 2 minutes; otherwise frames behind
|
||
and the worst capture-to-file time:
|
||
|
||
| Path | Workers × threads | Tower | Saves/s back to back | One frame every 2.0 / 1.5 / 1.0 / 0.7 / 0.5 s | Peak memory |
|
||
|---|---|---|---|---|---|
|
||
| Today: camera RGB8, d0.8 e4 | 1 × 4 | 121 | 1.33 | ok / – / ok / **3 behind** / – | 0.5 GB |
|
||
| Today: camera RGB8, d0.8 e4 | 1 × 4 | 111 | 0.88 | ok / – / **12 behind** / **65 behind** / – | 0.5 GB |
|
||
| **12-bit colour, d1.0 e3** | 1 × 4 | 121 | 1.44 | ok / ok / ok / **ok** / – | 0.7 GB |
|
||
| **12-bit colour, d1.0 e3** | 1 × 4 | 111 | 0.93 | ok / **ok** / **8 behind** / 60 behind / – | 0.7 GB |
|
||
| **12-bit colour, d1.0 e3** | 2 × 2 | 121 | 1.74 | – / – / ok / ok / **34 behind** | 1.1 GB |
|
||
| **12-bit colour, d1.0 e3** | 2 × 2 | 111 | 1.18 | – / – / **ok** / **28 behind** / 97 behind | 1.1 GB |
|
||
| 12-bit colour, d1.0 e3 | 4 × 1 | 121 / 111 | 1.99 / 1.37 | (back to back only; each save waits 2–3 s) | 2.0 GB |
|
||
| 12-bit colour, d0.5 e3 | 1 × 4 | 121 | 1.19 | – / – / ok / 24 behind / – | 0.8 GB |
|
||
| 12-bit colour, d0.5 e3 | 1 × 4 | 111 | 0.77 | – / – / 27 behind / 79 behind / – | 0.7 GB |
|
||
| 12-bit raw mosaic, lossless | 1 × 4 | 121 / 111 | 10.2 / 1.8 | ok at 1.0 and 0.7 on both | 0.3 GB |
|
||
|
||
On the real plant frame (lighter), one worker kept up with a frame every 1.0 s on both towers
|
||
(saved within 0.63 s on 121, 0.96 s on 111).
|
||
|
||
## Heat and power
|
||
|
||

|
||

|
||
|
||
10 minutes each:
|
||
|
||
| Load | Tower | Temp mean / max | Clock | CPU busy | Power | Saves per minute, first → last |
|
||
|---|---|---|---|---|---|---|
|
||
| Idle | 121 | 38 / 40 °C | 0.79 GHz | 2% | 2.2 W | – |
|
||
| Idle | 111 | 52 / 54 °C | 0.96 GHz | 4% | 2.9 W | – |
|
||
| 12-bit, one frame every 2 s (1 × 4) | 121 | **53 / 84 °C** | 1.71 GHz | 21% | **5.1 W** | 30 → 29 |
|
||
| 12-bit, one frame every 2 s (1 × 4) | 111 | **66 / 86 °C** | 1.84 GHz | 33% | **5.7 W** | 30 → 30 |
|
||
| 12-bit, flat out (2 × 2) | 121 | 80 / 87 °C | 2.88 GHz | 81% | 12.5 W | 122 → 103 (−16%) |
|
||
| 12-bit, flat out (2 × 2) | 111 | 78 / 83 °C | 2.44 GHz | 81% | 8.1 W | 74 → 73 |
|
||
|
||
At scan pace both computers stay far from their limits. The Delta runs ~13 °C warmer; flat out it
|
||
sits at an 8 W power limit, so it does not slow further, while the IOTA starts fast (12.5 W) and
|
||
settles 16% lower as it heats.
|
||
|
||
## Storage
|
||
|
||

|
||
|
||
Real plant frame sizes, 24 hours of scanning:
|
||
|
||
| Saved as | Per frame (121 / 111) | One frame / 2 s | / 5 s | / 10 s |
|
||
|---|---|---|---|---|
|
||
| Camera RGB8, d0.8 (today's quality) | 3.7 / 4.0 MB | 160–172 GB | 64–69 GB | 32–34 GB |
|
||
| **12-bit colour, d1.0** | **2.65 / 2.75 MB** | **114–119 GB** | **46–48 GB** | **23–24 GB** |
|
||
| 12-bit colour, d0.5 | 5.2 / 5.3 MB | 222–230 GB | 89–92 GB | 44–46 GB |
|
||
| 12-bit raw planes, lossless | 16.8 / 17.0 MB | 727–736 GB | 291–294 GB | 145–147 GB |
|
||
| 12-bit colour, lossless | 44.8 / 44.1 MB | ~1.9 TB | ~770 GB | ~385 GB |
|
||
|
||
The towers have 11–22 GB free, so only a short buffer can stay on the tower.
|
||
|
||
## Scope and caveats
|
||
|
||
- **One scene, two afternoons,** ~15 dB gain in the main runs; one 0 dB frame. Night is not measured.
|
||
- **Auto-exposure steps on a still camera,** not after real slews. Real waypoint-to-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 stream-while-saving test shows the camera link is unaffected by saving.
|
||
- **Detection quality is not measured.** PSNR and SSIM stand in for "what the algorithms receive".
|
||
- **Tower 111's own camera (1240c, 12 MP) was not tested here.** Its frames are 25% smaller, so the
|
||
Delta's limits above are conservative for it.
|
||
- **Slew time and mechanical settle** in the pace table are estimates, not measurements.
|
||
|
||
## Raw data and tools
|
||
|
||
- Programs and how to rerun every test: [tools/camera-bench](../tools/camera-bench/README.md).
|
||
- Camera tests, processing on own frames, heat: [tower 121](../tools/camera-bench/results-2026-10-02-tower121/),
|
||
[tower 111](../tools/camera-bench/results-2026-10-04-tower111/).
|
||
- Shared-frame processing: [tower 121](../tools/camera-bench/results-common-tower121/),
|
||
[tower 111](../tools/camera-bench/results-common-tower111/) (each with `frame_md5.txt`).
|
||
- Full-size example `.jxl` files (git-ignored, ~115 MB per set): `tools/camera-bench/examples-*`.
|
||
- Charts: `plot_results.py` and `plot_common.py`; example figures: `make_figures.py`;
|
||
quality scores: `compare_examples.py`.
|