455 lines
29 KiB
Markdown
455 lines
29 KiB
Markdown
# 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.
|