Dual IMX296 external trigger generator for the Raspberry Pi Pico 2 - hardware-synchronised stereo camera exposure
  • C 85.7%
  • CMake 9.4%
  • Python 4.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Bethany 5ad9689b05 Dual IMX296 external trigger generator for Pico 2
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>
2026-08-27 11:44:29 -07:00
include Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00
src Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00
.gitignore Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00
CMakeLists.txt Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00
conanfile.py Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00
example.c Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00
pico_sdk_import.cmake Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00
README.md Dual IMX296 external trigger generator for Pico 2 2026-08-27 11:44:29 -07:00

📸 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 8000 to 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