- C 85.7%
- CMake 9.4%
- Python 4.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Drives two InnoMaker CAM-IMX296RAW modules from one PWM slice so both sensors expose at the same instant. Left free-running, two IMX296 modules expose milliseconds apart. Measured on a Pi 5 rig via picamera2 SensorTimestamp, the offset was 21.54 ms in one session and 8.255 ms in another the same afternoon, each stable to under 0.002 ms. It is whatever phase the sensors powered up at, so it cannot be measured once and corrected: restarting the cameras reshuffles it. For stereo that offset turns any relative motion into false disparity, and on a moving platform it is the camera itself that moves -- at 1 m/s a rover accumulates 21 mm of ego-motion between its two eyes on every frame. Driving both sensors from one trigger removes it at the source. Measured in-situ: 8.255 ms -> 0.015 ms, a 550x improvement. The residual 15 us is optocoupler propagation mismatch and is steady to 1 us. Design notes: - GP16 + GP17 are channels A and B of a single PWM slice, so they share one counter and their edges align in hardware rather than by software timing. init() rejects any pin pair that does not qualify instead of silently degrading to two loosely-related signals. - One pin per camera, never both in parallel. Each opto input draws ~10.25 mA at 3.3 V through the on-board 200 ohm resistor; paralleling them would be 20.5 mA, over the RP2350's 12 mA per-pin figure. Pads are set to 12 mA drive because the 4 mA default gives soft edges into an optocoupler. - Callers ask for the exposure they want; the 14.26 us sensor-added offset is subtracted internally to derive the pulse. Verified sub-microsecond accurate from 30 us to 30 ms. - Continuous mode bottoms out at 8.94 Hz, where a frame period stops fitting the 16-bit counter. fire_once() covers below that with a single masked GPIO write, which is also the right primitive for capture-on-demand. The demo exposes a serial console for tuning rate and exposure without reflashing -- necessary because auto-exposure does nothing in trigger mode and pulse width alone sets brightness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| include | ||
| src | ||
| .gitignore | ||
| CMakeLists.txt | ||
| conanfile.py | ||
| example.c | ||
| pico_sdk_import.cmake | ||
| README.md | ||
📸 Lost + Found Dual IMX296 External Trigger Driver
Generates the external trigger for two InnoMaker CAM-IMX296RAW global-shutter camera modules from a single Raspberry Pi Pico 2 (RP2350), so both sensors expose at the same instant.
🎯 Why this exists
Two IMX296 modules left free-running do not expose together. Measured on a Pi 5 rig
via picamera2 SensorTimestamp, the two sensors sat 21.54 ms apart — a fixed phase
offset, constant to the sample, and not something software can close: it is when the
sensors expose, not when the host asks for a frame.
For stereo, that offset turns any relative motion into false disparity:
| Subject motion | Apparent error (f ≈ 968 px, Z ≈ 510 mm) |
|---|---|
| 5 mm/s | 0.20 px |
| 20 mm/s | 0.82 px |
| 40 mm/s | 1.64 px |
It is worse on a moving platform, where the camera is what moves: at 1 m/s a rover accumulates 21 mm of ego-motion between its two eyes on every frame — a bias no calibration can remove, because it is not a property of the rig's geometry.
Driving both sensors from one trigger removes the offset at the source.
🎛️ Hardware Description & Specifications
The camera's J3 header is an opto-isolated trigger input (on-board 200 Ω series
resistor, LED forward voltage ≈ 1.25 V). Exposure is controlled entirely by the pulse:
- Polarity: idle HIGH, exposure while LOW
- Exposure: LOW pulse width + 14.26 µs (sensor-added offset)
- Frame rate: set by how often you pulse — 30 Hz of pulses gives 30 fps
- Minimum exposure: ~30 µs
- Drive current: (3.3 − 1.25) / 200 ≈ 10.25 mA per camera
⚠️ Do not parallel both cameras onto one GPIO. That is 20.5 mA, over the RP2350's 12 mA per-pin recommendation. This driver uses one pin per camera.
🔌 Wiring Diagram (Pico 2)
| Camera Pin | Pico 2 GPIO | Pico 2 Physical Pin | Notes |
|---|---|---|---|
CAM0 J3 Trig+ |
GP16 | Pin 21 | PWM slice channel A |
CAM1 J3 Trig+ |
GP17 | Pin 22 | PWM slice channel B (same slice) |
Both J3 Trig− |
GND | Pin 23 | Common ground for both opto inputs |
Three adjacent physical pins, so it leaves the board as one 3-wire bundle.
Why GP16 + GP17 specifically
They are channels A and B of one PWM slice. A slice has a single counter, so giving
both channels the same compare value makes their edges align in hardware — no
software timing, no interrupt latency, nothing to drift. Any A/B pair on one slice works
(GP0+GP1, GP2+GP3, …); lf_imx296_trigger_init() rejects pins that do not qualify rather
than silently giving you two loosely-related signals.
Powering the Pico from the host's USB commons the grounds and forfeits the isolation the optocouplers provide. Harmless, but a separate supply keeps it if you want it.
🚀 Usage Example
#include "pico/stdlib.h"
#include "lf_imx296_trigger.h"
#include <stdio.h>
#define TRIG_PIN_A 16
#define TRIG_PIN_B 17
int main() {
stdio_init_all();
lf_imx296_trigger_t trig;
if (!lf_imx296_trigger_init(&trig, TRIG_PIN_A, TRIG_PIN_B)) {
printf("Failed to initialize trigger driver\n");
return -1;
}
// 30 fps, 8 ms exposure
lf_imx296_trigger_configure(&trig, 30.0f, 8000.0f);
lf_imx296_trigger_start(&trig);
while (true) {
tight_loop_contents();
}
}
For capture-on-demand — a rover grabbing a pair only while stationary — leave the
generator stopped and call lf_imx296_trigger_fire_once() per pair. That path also has
no minimum rate, unlike continuous mode.
🖥️ Host-side setup (Raspberry Pi)
The Pico is only half of it. On the host, before the pulses start:
1. Raise libcamera's frame timeout. In trigger mode no pulse means no frames, and
libcamera will abort the stream on its default timeout. In timeout.yaml, uncomment
camera_timeout_value_ms and set it to 60000, then:
export LIBCAMERA_RPI_CONFIG_FILE=timeout.yaml
2. Put each sensor into trigger mode. This is an I2C register write to the sensor
(0x30AE = 0x01), not a kernel module parameter:
sudo python3 i2c.py trigger on --bus <CAM0 i2c bus>
sudo python3 i2c.py trigger on --bus <CAM1 i2c bus>
Bus numbers are board-specific — check i2cdetect for the sensor at 0x1a rather than
copying them from the vendor manual.
⚠️ Auto-exposure stops working in trigger mode. Brightness is set by the pulse width alone, so expect to tune exposure once by eye. The demo's serial interface exists for exactly that:
e 8000to try 8 ms, no reflashing.
3. Verify. Re-measure the inter-eye SensorTimestamp skew. It should collapse from
tens of milliseconds to microseconds. That is a direct before/after on the real failure
mode rather than a proxy for it.
🧪 Demo
Build and flash example.c for an interactive serial console:
f <hz> set frame rate (e.g. 'f 30')
e <us> set exposure (e.g. 'e 8000')
s start pulsing
x stop pulsing
1 fire a single exposure (must be stopped)
? show status
📎 References
- InnoMaker
CAM-IMX296RAWuser manual, ch. 2.5 (J3 pinout) and ch. 4 (external trigger) - https://github.com/INNO-MAKER/cam-imx296raw-trigger