fwt_software/docs/camera-capture-measurements.md

455 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Camera capture measurements — towers 121 and 111
These are measured values, not estimates. They were taken to decide how auto mode can capture faster
at **full resolution, no binning, one quality setting, continuous acquisition**. That constraint is
the design decision these numbers serve. Two runs:
- **2026-09-30, tower 121:** camera frame rates, streaming, auto-exposure, white balance, first CPU
figures.
- **2026-10-01, towers 121 and 111:** JPEG XL compression in detail (input format, effort, threads,
quality setting), whether saving keeps up with the scan (pile-up), CPU heat, storage speed; and
the camera tests repeated on tower 111.
Each figure applies to the tower it was measured on (see [Scope](#scope-and-caveats)).
## Summary
**Camera and USB**
- **Full resolution does not cost frame rate on tower 121.** With 2×2 binning, its `standard`
profile runs at the same rate as full-resolution RGB8 (4.95 fps at 250 MB/s). The camera budgets the
link limit against the unbinned frame, so binning saves USB traffic but not time.
- **Raw Bayer is about 3× the frame rate of RGB8 over the same link** on both cameras. Tower 121:
BayerRG8 14.75 fps at 250 MB/s, 26.2 at 450 MB/s (RGB8 4.95 and 8.8). Tower 111: 14.2 and 20.6
(RGB8 5.9 and 9.6).
- **No format stalled on either tower.** Every format, at every link limit (up to 450 MB/s),
streamed for 60 s with nothing lost, also while the CPU was encoding. The only losses were 2
incomplete frames out of 1,574 on tower 121 at 450 MB/s under encode load. The stalls in
[known-issues.md](known-issues.md#camera-acquisition-usb3--root-cause--approach) did not
reproduce on either tower since the USB fixes (#19).
- **The camera applies white balance to raw Bayer output** exactly as to RGB8, on both cameras, so
colour conversion on the tower needs no white-balance step.
**Auto-exposure** (tower 121; tower 111's camera saw near-darkness, see below)
- **It settles faster at a higher frame rate, but not in proportion.** After a large brightness step
it reached ±10% in 1.4–1.8 s at 4.95 fps, 0.7–1.2 s at 14.75 fps and 0.4–0.7 s at 26.2 fps.
Today's 1000 ms `settle_wait_ms` at 4.95 fps is **shorter than a large step takes**, which fits the
badly exposed frames in known issue #16.
- **"Exposure and gain unchanged for N frames" is not a safe settle test.** Auto-exposure moves in
steps and pauses; the rule fired in 6 of 14 runs while brightness was still 17–300% off.
- **From a dark step it overshoots to its maximum exposure** (3.4–4.1× too bright) before coming
back down.
- **The camera's per-frame data (chunk mode) works** on both cameras: exposure, gain and a camera
timestamp on every frame.
**JPEG XL compression and saving** (2026-10-01; times include writing the file)
- **The encoder takes ordinary images only:** one channel (grey) or three (RGB). It knows nothing of
Bayer patterns, so raw data is either converted to colour first or stored as a one-channel mosaic,
which must be lossless.
- **Converting raw to colour on the tower costs almost nothing:** 5–13 ms, then the same encode time
as the camera's own RGB8. The colour *encode* is the cost: on a detailed full frame, 0.74 s on
tower 121 (16 MP) and 0.86 s on tower 111 (12 MP), with 4 threads at today's quality setting.
- **Effort 1–4 cost about the same; effort 5 is 3–5× slower.** Effort 3 gave the smallest files of
effort 1–4.
- **Production's 3 encoder threads are a poor choice.** On tower 121, 3 threads was slower than 2;
4 threads was fastest on both towers.
- **One save worker cannot keep up with a frame every 0.7 s:** colour images piled up on both towers
(9–29 behind after 2 minutes, saved up to 21 s late). **Two workers with 2 threads each kept up**
on both. One worker handled a frame every 1.0 s on both.
- **Lossless raw saving uses a tenth of the CPU** (0.19–0.25 CPU-s per frame against 1.7–1.9), but
files are 2–3× larger, and on tower 111 the storage is the limit: its eMMC writes at 76 MB/s
(tower 121: 218 MB/s).
- **Heat, in the field enclosure:** flat out, the CPU levels at 75–79 °C with peaks of 88–90 °C.
At one colour frame every 2 s: 53 °C on tower 121 and 63 °C on tower 111. Tower 121's throughput
drifted down 13% over 10 minutes flat out; tower 111's by 4%.
**Bugs found**
- **`jxl_distance = 0` is not lossless** in the production encoder (known issue #20): decoded pixels
differ by up to 1–2 levels.
- **12-bit lossless needs an explicit bit-depth setting** in libjxl, or 12-bit values held in 16-bit
words are rescaled. This affected only the test program; 12-bit figures before 2026-10-01 were
wrong and are corrected below.
## Setup
| | Tower 121 | Tower 111 |
|---|---|---|
| Computer | LattePanda IOTA: Intel N150, 4 cores / 4 threads, 0.7–3.6 GHz, 7 GB | LattePanda 3 Delta: Intel Celeron N5105, 4 cores / 4 threads, 0.8–2.9 GHz, 7.6 GB |
| OS | Ubuntu 26.04.1, kernel 7.0.0-31 | Ubuntu 22.04.5, kernel 6.8.0-138 |
| Storage | eMMC, 218 MB/s write (measured) | eMMC, 76 MB/s write (measured) |
| Camera | Alvium 1800 U-1620c, `DEV_1AB22C0B7D4E`, firmware 14.3.4646D35D, 5328×3040 (16.2 MP) | Alvium 1800 U-1240c, `DEV_1AB22C049CD1`, firmware 13.1.794391F9, 4024×3036 (12.2 MP) |
| Profile in use (`standard`) | RGB8, 2×2 binning, 4.95 fps, 250 MB/s, exposure ≤ 20 ms, `MinimizeBlur` | RGB8, no binning, 1.09 fps, 200 MB/s, exposure ≤ 9 s, `MinimizeNoise` |
| Libraries | vmbpy 1.2.0 (Python 3.14), libjxl 0.11.1, OpenCV 4.10.0 | vmbpy 1.0.5 (Python 3.10), libjxl 0.11.0, OpenCV 4.5.4 |
| Tower program | `f2c61e0-dirty` | `8d81e03` (same JPEG XL encoder source) |
| Enclosure | field enclosure (confirmed) | field enclosure |
| Scene | Indoors facing a window, badly out of focus (known issue #15). 2026-09-30: grey, noisy, gain at 24 dB. 2026-10-01: nearly black, a blurred blue light, exposure and gain at their limits. | Near-darkness: mean pixel value 7 of 255 at 20 ms and 27 dB. |
| USB preconditions | `usbfs_memory_mb` 1000, LPM off ([#19](known-issues.md)) | same |
The tower program was **stopped for every test**: the camera accepts one program at a time, and the
CPU figures should have one cause. Nothing here is measured under the program's own load.
## Method
The programs are in [tools/camera-bench](../tools/camera-bench/README.md).
**Camera** (`camera_bench.py`, vmbpy): full sensor, binning 1. Its frame handler only records
metadata and hands the buffer back, so it cannot cause the losses it counts. On tower 111
auto-exposure was capped at 20 ms (tower 121's profile value), or its 0.7 s exposures would have
limited the frame rate instead of the link.
1. **Maximum frame rate** per pixel format and link limit, as the camera reports it.
2. **Streaming** 60 s at that rate, 5 buffers (as production): frames, incomplete frames, gaps in the
camera's frame IDs, gaps over 1 s.
3. **Streaming under load:** the same while encoding colour JXL in a loop.
4. **Auto-exposure step:** settle 8 s, force brightness (exposure × gain) to 1/16 or up to 16×, hold
2 s, release, and log per-frame exposure and gain for 15 s; twice per setting.
5. **White balance on raw:** exposure and gain fixed; red and blue `BalanceRatio` at 1.0 and 2.0;
compare the mean at those pixel sites in BayerRG8 and RGB8.
**Compression** (`encode_bench.cpp`), on full-resolution frames in the camera's formats. Colour
lossy encodes use the **production** `src/camera/JpegXlEncoder.cpp` unchanged. Lossless and 12-bit
encodes use a test-only encoder that sets lossless mode correctly (see #20). Every time includes
writing the file, as production does. Two input frames per tower:
- **the real frame** the camera saw that day (dark and blurred, so it compresses unusually easily);
- **a synthetic frame** at the camera's resolution: a "dead leaves" texture (the camera texture
chart of ISO 19567-2) with mild sensor noise, sharp and detailed at every scale, as the realistic
worst case ([image](../tools/camera-bench/results-2026-10-01-tower121/synthetic.jpg)).
Input kinds: `rgb8` (the camera's colour), `bilinear` and `ea` (raw converted to colour on the tower,
OpenCV bilinear or edge-aware), `raw8` and `raw12` (the raw mosaic, lossless), `colour12` (12-bit raw
converted to 16-bit colour, lossy).
6. **Matrix:** every colour input at effort 1–5 × 1–4 threads (quality setting `distance` 0.8, as
production); a distance sweep 0.5–3.0 with PSNR against the input; lossless raw at effort 1–3 ×
1–4 threads. Median of 3 runs.
7. **Throughput:** back-to-back saves for 60 s, 1 worker × 4 threads and 2 workers × 2 threads.
8. **Pile-up:** one frame queued every 2.0, 1.0 or 0.7 s for 2 minutes, as a scan keeps one frame
per waypoint; time from queueing to file written, and the queue left at the end.
9. **Heat:** idle baseline; 10 min of back-to-back colour saves; 5 min cool-down; 10 min at one
frame every 2 s. CPU package temperature, average clock, CPU load and package power (RAPL) once a
second.
10. **Storage:** `dd` of 256 MB with a final sync.
At the end of each tower's run the camera profile was reloaded, frames were confirmed to flow, and
the tower program was restarted; both came back on their `standard` profile.
## Results: camera and USB
### Maximum frame rate and streaming, full resolution
Received / incomplete / lost frames over 60 s at the maximum rate.
**Tower 121** (5328×3040):
| Format | Frame | 250 MB/s: max fps | 60 s at 250 | 450 MB/s: max fps | 60 s at 450 |
|---|---|---|---|---|---|
| BayerRG8 | 16.2 MB | **14.75** | 886 / 0 / 0 | **26.17** | 1570 / 0 / 0 |
| BayerRG10p | 20.2 MB | 11.82 | not streamed | 20.95 | not streamed |
| BayerRG12p | 24.3 MB | **9.86** | 592 / 0 / 0 | **17.52** | 1053 / 0 / 0 |
| RGB8 | 48.6 MB | **4.95** | 298 / 0 / 0 | **8.79** | 527 / 0 / 0 |
| RGB8, 2×2 binning (`standard`) | 12.1 MB | 4.95 | 50 / 0 / 0 in 10 s, 60 MB/s actual | not measured | not measured |
While encoding: BayerRG8 at 450 MB/s 1574 received, **2 incomplete**; BayerRG8 at 250 and RGB8 at 250
clean.
**Tower 111** (4024×3036):
| Format | Frame | 200 MB/s: max fps | 60 s at 200 | 250 MB/s: max fps | 60 s at 250 | 450 MB/s: max fps | 60 s at 450 |
|---|---|---|---|---|---|---|---|
| BayerRG8 | 12.2 MB | **12.10** | 717 / 0 / 0 | **14.25** | 854 / 0 / 0 | **20.60** | 1235 / 0 / 0 |
| BayerRG10p | 15.3 MB | 10.20 | not streamed | 12.10 | not streamed | 18.06 | not streamed |
| BayerRG12p | 18.3 MB | **8.80** | 527 / 0 / 0 | **10.52** | 632 / 0 / 0 | **16.01** | 962 / 0 / 0 |
| RGB8 | 36.7 MB | **4.84** | 290 / 0 / 0 | **5.90** | 354 / 0 / 0 | **9.61** | 575 / 0 / 0 |
While encoding: BayerRG8 at 450 and 250, and RGB8 at 250, all clean (1235, 854 and 353 frames).
In every run the measured rate was within 1.5% of the maximum and the longest gap between frames was
about one frame period. Tower 111's raw rates rise less than in proportion to the link limit
(20.6 fps at 450 MB/s carries 251 MB/s), so above 250 MB/s something other than the link limits
that camera.
### Auto-exposure step response (tower 121, BayerRG8)
Times from switching auto-exposure back on. *±10%*: first frame within 10% of the pre-step
brightness. *Stopped changing*: last change of exposure or gain in the 15 s run. *3-unchanged rule*:
first frame after which exposure and gain stayed the same for 3 frames.
| fps | Reaction rate | Step | ±10% | Stopped changing | 3-unchanged rule fired |
|---|---|---|---|---|---|
| 26.17 | 100 | dark ×1/16 | 0.47 s (12 frames), 0.66 s (17) | 0.89 s (23), 1.27 s (33) | 0.62 s, **0.09 s** |
| 26.17 | 100 | bright ×4.1, ×3.7 | 0.54 s (14), 0.39 s (10) | 0.93 s (24), 0.93 s (24) | **0.09 s**, 0.77 s |
| 14.75 | 100 | dark ×1/16 | 1.04 s (15), 0.91 s (13) | 1.17 s (17), 1.18 s (17) | **0.16 s**, **0.16 s** |
| 14.75 | 100 | bright ×4.0, ×3.6 | 1.18 s (17), 0.70 s (10) | 1.45 s (21), 1.38 s (20) | **0.16 s**, 1.11 s |
| 4.95 | 100 | dark ×1/16 | 1.58 s (7), 1.79 s (8) | 2.19 s (10), 2.59 s (12) | 2.59 s, 3.00 s |
| 4.95 | 100 | bright ×3.8, ×3.5 | 1.38 s (6), 1.38 s (6) | 1.79 s (8), 2.59 s (12) | 2.19 s, 3.00 s |
| 14.75 | **50** | dark ×1/16 | 2.19 s (32) | 3.75 s (55) | **0.16 s** |
| 14.75 | **50** | bright ×3.4 | 1.99 s (29) | 3.48 s (51) | **0.16 s** |
Bold: the rule fired too early, on frames that still carried the forced values. The bright step
reached only ×3.4–4.1 because gain was already at its maximum. In every run exposure and gain ended
within 2% of the pre-step value. From the per-frame series:
- **Once within ±10%, brightness stayed there,** so that column is a real settle time.
- **Old values persist for 1–5 frames** after auto-exposure resumes.
- **Auto-exposure moves in steps and pauses:** holds of 3+ unchanged frames while still more than 10%
off, 0–3 times per run at rate 100 and 5–6 at rate 50. Even skipping the first 3–4 frames, "unchanged
for 3 or 4 frames" fired while 17–300% off in 6 of 14 runs.
- **Overshoot from a dark step:** auto-exposure first jumps to `ExposureAutoMax` (20 ms) and comes
back down, here 3.4–4.1× too bright.
**Tower 111** saw near-darkness, so with exposure capped at 20 ms both exposure and gain sat at their
limits. The bright step could not be forced at all, and the dark step only measures recovery back to
the limits: 1.25 s (13 frames) at 20.6 fps, 1.56–1.65 s (10–12) at 14.2 fps, 3.6–3.8 s (12–13) at
5.9 fps, 2.43 s at reaction rate 50. Treat these as a lower bound on its settling, not a measurement
of it.
### White balance on raw output
Mean pixel values at those sites, exposure and gain fixed:
| Setting | 121 raw red | 121 RGB8 red | 121 raw blue | 121 RGB8 blue | 111 raw red | 111 RGB8 red | 111 raw blue | 111 RGB8 blue |
|---|---|---|---|---|---|---|---|---|
| Red ratio 1.0 | 58.3 | 57.8 | 104.3 | 102.8 | 2.55 | 6.03 | 7.76 | 17.65 |
| Red ratio 2.0 | 82.4 | 81.4 | 104.9 | 103.3 | 5.54 | 12.47 | 7.75 | 17.55 |
| Blue ratio 1.0 | 99.2 | 98.1 | 74.0 | 73.3 | 7.07 | 16.46 | 4.10 | 9.60 |
| Blue ratio 2.0 | 99.1 | 98.9 | 104.0 | 103.5 | 7.11 | 16.44 | 8.70 | 19.50 |
On both cameras the ratios change raw pixels as they change RGB8. On tower 111 doubling a ratio
doubled the values (×2.07–2.17). On tower 121 it gave only ×1.41; that frame had 21% of its pixels
clipped at white, which cannot get brighter, and this is the likely reason. On tower 111 the RGB8
values are about 2.3× the raw ones at the same settings; not explained.
The camera's `BayerRG8` is OpenCV's **`COLOR_BayerBG2RGB`**, not `BayerRG2RGB`: de-Bayering with it
gives the same channel order as the camera's own RGB8.
## Results: JPEG XL compression and saving
### What the encoder takes
libjxl encodes ordinary images: one channel (grey) or three (RGB), 8 or 16 bits per value. Raw
camera data reaches it in one of two ways:
- **Converted to colour** (by the camera as RGB8, or on the tower), then encoded **lossy**: libjxl
converts to a perceptual colour space, transforms blocks and discards what the eye is least likely
to miss. `distance` sets how much (1.0 is meant to be visually lossless; production uses 0.8).
- **As the raw mosaic in one grey channel**, which must be **lossless**: lossy compression would blend
neighbouring pixels of different colours and ruin colour after de-Bayering. The file then needs
de-Bayering after decoding.
Not tested: splitting the mosaic into four quarter-size planes (R, G, G, B) and storing them as
channels, a known technique that compresses raw data better and could allow light lossy compression.
### Every input at production quality (distance 0.8 or lossless), 4 threads
Time per frame including the file write, and file size. Synthetic (detailed) frame first, then the
real frame.
| Input | Tower 121, 16.2 MP | Tower 111, 12.2 MP |
|---|---|---|
| `rgb8`, camera's colour, effort 4 | 0.74 s, 7.9 MB · real 0.47–0.50 s, 1.7 MB | 0.87 s, 6.0 MB · real 0.62 s, 1.8 MB |
| `rgb8`, effort 4, **3 threads (production today)** | 0.92 s · real 0.63 s | 0.93 s · real 0.69 s |
| `bilinear`, tower colour, effort 4 | 0.73 s, 7.0 MB · real 0.46 s, 1.2 MB | 0.84 s, 5.3 MB · real 0.49 s, 0.7 MB |
| `ea`, tower colour, effort 4 | 0.74–0.79 s, 7.2 MB · real 0.47–0.49 s, 1.3 MB | 0.86 s, 5.4 MB · real 0.52 s, 0.8 MB |
| `colour12`, 12-bit tower colour, effort 4 | 0.69 s, 7.0 MB · real 0.49 s, 1.2 MB | 0.94 s, 5.2 MB · real 0.59 s, 0.8 MB |
| `raw8` lossless, effort 1 | 0.10 s, 17.9 MB · real 0.06 s, 11.2 MB | 0.23 s, 13.5 MB · real 0.15 s, 6.8 MB |
| `raw12` lossless, effort 1 | 0.07 s, 26.0 MB · real 0.09 s, 19.3 MB | 0.40 s, 19.6 MB · real 0.19 s, 13.0 MB |
The conversion to colour on the tower took 5–16 ms (bilinear or edge-aware), and 23–41 ms for 12-bit.
On the synthetic frame, 12-bit lossless is *larger* than the 24.3 MB the camera sends: sensor noise in
the low bits does not compress.
### Effort and threads (synthetic frame, `ea`, distance 0.8)
| Effort | 121: 1 / 2 / 3 / 4 threads | 121 file | 111: 1 / 2 / 3 / 4 threads | 111 file |
|---|---|---|---|---|
| 1 | 884 / 685 / 786 / 620 ms | 7.4 MB | 1257 / 849 / 731 / 730 ms | 5.6 MB |
| 2 | 882 / 717 / 792 / 587 ms | 7.4 MB | 1253 / 862 / 785 / 718 ms | 5.6 MB |
| 3 | 1027 / 788 / 890 / 645 ms | 7.0 MB | 1385 / 949 / 803 / 768 ms | 5.3 MB |
| 4 | 1070 / 839 / 907 / 742–788 ms | 7.1 MB | 1494 / 1005 / 847 / 858 ms | 5.4 MB |
| 5 | 5637 / 3500 / 2954 / 2440 ms | 8.3 MB | 7287 / 4242 / 3541 / 3161 ms | 6.3 MB |
`rgb8` and `bilinear` follow the same pattern within about 10% (full tables:
`summarize_encode.py`, see [Raw data](#raw-data-and-tools)).
- **Effort 1–4 are within about 20% of each other; effort 5 is 3–5× slower** and gave larger files
on the detailed frame (smaller ones on the dark real frames). Effort 3 gave the smallest files of 1–4, at effort 4's speed or better.
- **Tower 121: 3 threads is slower than 2** for every colour input and effort, on both frames (and
for most lossless rows); 4 is fastest. The cause is not known. Tower 111 behaves as expected: each thread helps, though 3 and 4
are close.
- **Per pixel, tower 111 is about 55% slower** than tower 121.
### Quality setting (effort 4, 4 threads, synthetic frame)
PSNR against the input; higher is closer. On this noisy synthetic frame PSNR counts removed noise as
loss, so compare settings with each other, not with other images.
| distance | 121 `rgb8` | 121 `ea` | 111 `rgb8` | 111 `ea` |
|---|---|---|---|---|
| 0.5 | 0.84 s, 10.8 MB, 29.7 dB | 0.81 s, 9.8 MB, 31.7 dB | 0.92 s, 8.1 MB, 29.7 dB | 0.94 s, 7.4 MB, 31.7 dB |
| **0.8** | 0.79 s, 7.9 MB, 27.6 dB | 0.74 s, 7.2 MB, 29.4 dB | 0.85 s, 6.0 MB, 27.6 dB | 0.83 s, 5.4 MB, 29.4 dB |
| 1.0 | 0.68 s, 7.1 MB, 26.9 dB | 0.73 s, 6.4 MB, 28.7 dB | 0.80 s, 5.3 MB, 26.9 dB | 0.87 s, 4.8 MB, 28.8 dB |
| 1.5 | 0.69 s, 5.0 MB, 25.2 dB | 0.66 s, 4.5 MB, 27.0 dB | 0.68 s, 3.8 MB, 25.2 dB | 0.67 s, 3.4 MB, 27.0 dB |
| 2.0 | 0.64 s, 4.2 MB, 24.6 dB | 0.62 s, 3.8 MB, 26.2 dB | 0.65 s, 3.2 MB, 24.6 dB | 0.64 s, 2.9 MB, 26.3 dB |
| 3.0 | 0.59 s, 3.4 MB, 23.8 dB | 0.58 s, 3.1 MB, 25.4 dB | 0.62 s, 2.5 MB, 23.8 dB | 0.61 s, 2.3 MB, 25.4 dB |
From 0.5 to 3.0, files shrink about 3× and encoding gets only about 30% faster. The quality setting
is a storage decision more than a speed one.
### Keeping up with the scan (synthetic frame, distance 0.8, effort 4; raw8 effort 1)
*Saved after*: time from queueing the frame to the file being written, typical / worst. *Behind*:
frames still queued when the 2 minutes ended.
| Input | Workers × threads | One frame every | 121: saves/s, saved after, behind | 111: saves/s, saved after, behind |
|---|---|---|---|---|
| `rgb8` | 1 × 4 | back to back | 1.32 /s | 1.19 /s |
| `rgb8` | 2 × 2 | back to back | 1.72 /s | 1.58 /s |
| `ea` | 1 × 4 | back to back | 1.31 /s | 1.23 /s |
| `ea` | 2 × 2 | back to back | 1.77 /s | 1.62 /s |
| `raw8` | 1 × 4 | back to back | 9.90 /s | 3.74 /s |
| `raw8` | 2 × 2 | back to back | 13.1 /s | 5.10 /s |
| `rgb8` | 1 × 4 | 2.0 s / 1.0 s | 0.7 / 0.8 s, none | 0.8 / 1.0 s, none |
| `rgb8` | 1 × 4 | **0.7 s** | **3.5 / 7.4 s, 9 behind** | **11.1 / 21.4 s, 29 behind** |
| `ea` | 1 × 4 | 2.0 s / 1.0 s | 0.7 / 0.8 s, none | 0.8 / 1.1 s, none |
| `ea` | 1 × 4 | **0.7 s** | **1.5 / 4.2 s, 4 behind** | **8.9 / 17.0 s, 23 behind** |
| `ea` | 2 × 2 | 1.0 s | 0.8 / 0.9 s, none | 1.0 / 2.1 s, none |
| `ea` | 2 × 2 | 0.7 s | 0.9 / 1.1 s, none | 1.2 / 1.6 s, none |
| `raw8` | 1 × 4 | 2.0 / 1.0 / 0.7 s | 0.1 / 0.16 s, none | 0.22 / 1.6 s, none |
| `ea`, real frame | 1 × 4 | 1.0 s | 0.48 / 0.57 s, none | 0.48 / 0.59 s, none |
- **One worker keeps up to about one colour frame per 0.8 s (121) or 0.85 s (111)**; faster than
that, the queue grows without limit (the production queue has none).
- **Two workers × 2 threads raise that by about 30%**, at the cost of each image taking a little
longer (about 1.1–1.2 s instead of 0.8 s), because one encode does not keep 4 cores busy: one
worker × 4 threads kept the CPU 60–64% busy.
- **Raw lossless** keeps up easily; on tower 111 its occasional slow saves (up to 1.6 s) come from
storage (see below).
### Heat and power (field enclosure)
| | Tower 121 | Tower 111 |
|---|---|---|
| Idle, program stopped | 37 °C, 0.78 GHz, 2.2 W | 51 °C, 1.01 GHz, 2.9 W |
| Idle, program running (before the tests) | 39 °C | 64 °C |
| One `ea` frame every 2 s, 10 min | **53 °C** mean (peaks 83), 1.79 GHz, 5.2 W, 23% busy | **63 °C** mean (peaks 84), 1.77 GHz, 5.0 W, 26% busy |
| Flat out, 10 min (1 × 4) | **75 °C** mean (peaks 88), 2.82 GHz, 11.2 W | **77 °C** mean (peaks 90), 2.51 GHz, 7.9 W |
| Throughput over the 10 min flat out | 92 → 80 per minute (**−13%**) | 76 → 73 per minute (−4%) |
| Back to idle after flat out | ~38 °C within 3–4 min | not separately measured |
| CPU-seconds per colour frame | 1.7–1.9 | 1.7–1.9 |
| CPU-seconds per raw lossless frame | 0.19–0.21 | 0.23–0.25 |
Minute by minute, flat out, the temperature levelled after 2 minutes on both towers and the clock
averages stayed flat. Tower 121's throughput still fell 13%, so it slowed in a way the one-second
clock average does not show. At one frame every 2 s nothing slowed on either tower: 30 frames every
minute. Peaks are momentary, during each encode, and stay well below the usual 100–105 °C junction limit
of these CPUs.
### Storage
256 MB written with a final sync: **tower 111 76 MB/s, tower 121 218 MB/s** (both eMMC). A
13–18 MB raw file takes 0.17–0.24 s just to write on tower 111, which caps it at about 4 raw saves per
second whatever the CPU could do. Colour files (1–8 MB) are not limited by it.
### CPU cost per frame, first figures (tower 121, 2026-09-30)
Real frame of that day (grey, noisy), 3 threads unless stated.
| Step | Time | File size |
|---|---|---|
| Copy a frame (as `FrameReceived` does for every frame) | 1.6 ms (BayerRG8), 4.2 ms (RGB8) | – |
| De-Bayer, OpenCV bilinear / edge-aware / VNG | 8.0 / 4.1 / 107 ms | – |
| Rotate 180° (RGB8) | 31 ms | – |
| Colour JXL, distance 0.8, effort 1 / 2 / 3 / **4** / 5 | 655 / 619 / 672 / **670** / 2466 ms | 2.82 / 2.82 / 2.58 / **2.65** / 2.23 MB |
| same, 4 threads | 412 / 426 / 464 / **504** / 2049 ms | same |
| same, effort 4, while streaming | 711–742 ms | – |
| Raw BayerRG8, truly lossless, effort 1 / 2 / 3 | 98 / 400 / 520 ms | 9.29 / 8.44 / 8.24 MB |
| Raw BayerRG8 through production `jxl_distance = 0` (**not lossless**), effort 1–4 | 718–789 ms | 14.9–15.1 MB |
The 12-bit figures first published in this row were wrong (see
[12-bit lossless](#12-bit-lossless-needs-a-bit-depth-setting)); the corrected 12-bit results are in
the tables above.
### `jxl_distance = 0` is not lossless
`JpegXlEncoder::encodeToFile` sets `basic_info.uses_original_profile = JXL_FALSE` in every case. For
distance 0, libjxl prints `Set uses_original_profile=true for lossless encoding`, still returns
success, and encodes through its lossy colour space:
| Input | File | Pixel values differing | Largest difference |
|---|---|---|---|
| BayerRG8 (1 channel) | 15.1 MB | 243,403 of 16.2 M (1.5%) | 1 |
| RGB8 (3 channels) | 31.2 MB | 3,564,870 of 48.6 M (7.3%) | 2 |
`config.example.ini` and the encoder header both describe distance 0 as lossless. Known issue #20.
### 12-bit lossless needs a bit-depth setting
When 12-bit values are passed in 16-bit words, libjxl by default reads them as the full 0–65535
range and rescales, so a "lossless" encode keeps only the top bits: in a round-trip test every pixel
changed, by up to 3,839. Setting `JxlEncoderSetFrameBitDepth(JXL_BIT_DEPTH_FROM_CODESTREAM)` makes it
exact (0 differences). The production encoder is 8-bit only and not affected; the test program was,
and all 12-bit figures before 2026-10-01 (12-bit lossless 8.4 MB and 0.3 s, smaller than 8-bit) were
wrong. Any future 12-bit save path needs this setting.
## What this means for the design
- **Format.** Raw Bayer gives about 3× the frames of RGB8 over the same link on both cameras.
Converting it to colour on the tower costs 5–16 ms and no white-balance work, and the colour encode
afterwards costs the same as for the camera's RGB8. So "raw from the camera, colour on the tower"
gains frame rate (faster auto-exposure) without costing save time. Saving the mosaic losslessly
instead costs a tenth of the CPU and heat, but 2–3× the storage, and is limited by tower 111's
storage speed.
- **Saving.** One save worker handles about one colour frame per 0.8 s; the production queue has no
limit, so a faster scan would fall further behind for as long as it runs. Two workers × 2 threads
add about 30%. Production's 3 encoder threads should be 4 (one worker) or 2 (two workers). Effort
3 is as fast as 4 with smaller files here; effort 5 is not usable at scan pace.
- **Heat.** At scan paces of a frame every 1–2 s both towers ran far from throttling (53–71 °C).
Flat-out encoding for 10 minutes reached 75–79 °C with momentary peaks of 90 °C and a small
slowdown on tower 121. Tower 111 runs 10–14 °C hotter than tower 121 at rest and at scan pace.
- **Settling.** A higher frame rate settles faster (about 1.6 s at 4.95 fps to 0.5 s at 26 fps for the
same ×16 step). Exposure and gain holding still is not enough to know auto-exposure has finished;
the kept frame's brightness (mean against the camera's target, clipped share) has to be part of the
test, with exposure and gain at a limit marking when waiting cannot help. **Not yet measured.**
- **Link limit.** 450 MB/s was clean on both towers alone and nearly clean under load. If used, the
capture path should count and skip incomplete frames.
- **Profile.** On tower 121, `MinimizeBlur` raises gain to its 24 dB maximum before exposure goes past
about 5 ms. With the gimbal standing still, `MinimizeNoise` may give cleaner images. Not tested.
## Scope and caveats
- **Two towers, two cameras, two computers.** Tower 121 (IOTA, 1800 U-1620c) and tower 111 (3 Delta,
1800 U-1240c). Other Delta towers have the same board as 111 but were not measured.
- **The tower program was stopped throughout.** Nothing was measured under its own load (camera
stream, frame copies, control loop, MQTT), nor in auto mode.
- **Binning was not tested** beyond tower 121's maximum frame rate and a 10 s stream with its binned
profile, by design (full resolution only).
- **Poor scenes on both towers.** Tower 121 indoors and out of focus; tower 111 in near-darkness.
The synthetic frame stands in for a sharp outdoor view in the compression tests; auto-exposure in
daylight, and on tower 111 at all, is not measured.
- **A step on a still camera is not a slew.** Settling after real slews needs the per-waypoint log in
the scan program.
- **60 s streams, 10 min heat runs.** Hours-long stability and summer ambient temperatures are not
covered; the ambient temperature during the runs was not recorded.
- **Not measured:** BayerRG10p streaming; `IntensityControllerTolerance` and `Target`; colour quality
of the tower's de-Bayer against the camera's; the four-plane raw storage option.
- **vmbpy 1.2.0 cannot stream on tower 121's Python 3.14 as installed** (`NameError: name 'Camera' is
not defined` from its runtime type check); the test script bypasses the check in its own process.
The production program (C++) is not affected.
- **Per-frame data** (`ChunkEnable`) can only be switched per item while `ChunkModeActive` is off.
Chunk data adds 48 bytes per frame.
## Raw data and tools
The programs and how to rerun them: [tools/camera-bench](../tools/camera-bench/README.md). Raw
results, one JSON line per measurement (including per-frame auto-exposure series and per-second heat
samples):
- [results-2026-09-30-tower121/](../tools/camera-bench/results-2026-09-30-tower121/) — camera tests
and first CPU figures, with `scene.jpg`.
- [results-2026-10-01-tower121/](../tools/camera-bench/results-2026-10-01-tower121/) — compression,
pile-up, heat; `matrix12_*.jsonl` are the corrected 12-bit rows; `scene.jpg` and `synthetic.jpg`.
- [results-2026-10-01-tower111/](../tools/camera-bench/results-2026-10-01-tower111/) — the same, and
tower 111's camera tests (`camera_*.jsonl`).
`python3 tools/camera-bench/summarize_encode.py <results folder>` prints every compression, pile-up
and heat table in full.