English · 한국어
Control, record and analyse USB3 Vision industrial cameras on macOS without any vendor driver.
IDS ships no macOS driver, SDK or tool. IDS peak — the vendor software for these cameras — is Windows and Linux only, and the classic uEye suite likewise. Plug the camera into a Mac and nothing happens: no capture, no configuration, not even a viewer.
None of it turns out to be necessary. These cameras are fully USB3 Vision compliant and macOS leaves the device unclaimed, so libusb can drive it directly from userspace — no root, no kext, no disabling SIP. This project is what that makes possible: a complete control, recording and analysis tool built on the open standard instead of a vendor SDK.
It was written for measuring scintillator light from a particle beam, which is where the calibration and event-capture features come from, but nothing in it is specific to that — they apply to any faint or transient light measurement.
Any USB3 Vision camera should work, whatever the vendor. For IDS specifically:
| Family | Examples | Expected |
|---|---|---|
U3- (uEye+) |
U3-3xxx CP / XCP / SE / XLE | works — USB3 Vision + GenICam |
UI- (classic uEye) |
UI-3xxx and older | does not work — IDS proprietary transport |
Verified on one U3-31A0SE-M-GL Rev.1.2. Other U3- models are expected to work because the
transport and the feature access are standard, but they have not been measured — plug one
in and ./camrec.py check will report exactly what it supports. See
Will other cameras work for detail.
IDS Imaging Development Systems GmbH — U3-31A0SE-M-GL Rev.1.2 (order no. 1010392)
Sony IMX426LLJ-C · 816 × 624 (0.51 MP) · CMOS mono · global shutter · 12-bit ADC · USB 3.2 Type-C · C-mount
Every number in this document was measured on this one unit. See Camera specifications for details.
The camera reports its DeviceModelName as U3-31AxSE-M — a family-level name that
differs from the datasheet part number (U3-31A0SE-M-GL), so it is normal for check and
info output not to match the part number exactly. DeviceFirmwareVersion is 3.70.25496.
- Why this exists · Which cameras work · Camera used
- Why no vendor driver is needed
- Other operating systems · Installation
- Quick start
- GUI
- Unplugging the camera
- CLI commands
- Camera physics you need to know
- Shutter duty cycle
- Triggering
- Beam exposure calibration (autoexpose)
- Background collection (background)
- Automatic recording (watch)
- Output formats and recorded metadata
- Settings file
- Output folders
- Measured performance
- Will other cameras work
- Limitations
- Troubleshooting
- Structure
- Camera specifications · Dependencies · License
The IDS peak SDK supports Windows and Linux only — there is no macOS build. This works anyway because:
The camera is fully compliant with USB3 Vision + GenICam. Verified by reading the USB descriptors directly:
=== 1409:8000 bcdUSB=0x0320 devClass=0xef/0x02/0x01
IF 0 class=0xef sub=0x05 proto=0x00 <== U3V CONTROL (BULK 0x01 out / 0x81 in)
IF 1 class=0xef sub=0x05 proto=0x02 <== U3V STREAMING (BULK 0x82 in)
IF 2 class=0xef sub=0x05 proto=0x01 <== U3V EVENT (BULK 0x83 in)
libusb_open : OK claim interface 0 : OK
The 0xEF/0x05/{00,01,02} three-interface layout is the signature the USB3 Vision spec
mandates. macOS binds no driver to bDeviceClass 0xEF (Miscellaneous), so libusb claims
the interface directly in userspace — no root, no kext, no disabling SIP.
The camera carries its own GenICam XML (63 KB, zipped), so the host learns its features at runtime. That is why a vendor-specific driver is unnecessary in principle.
GenCP version : 1.3 Manufacturer : IDS Imaging Development Systems GmbH
Model : U3-31AxSE-M Serial : 4110033014
Manifest table: 0x150000 GenICam XML : 63,687 bytes (PK\x03\x04 = zip)
The camera logic is OS-agnostic apart from two error messages — beam.py, background.py
and watch.py are plain Python over Aravis, camera.py branches only to say how to list USB
devices on your platform, and record.py only to name the ffmpeg install command. Aravis,
libusb, ffmpeg, PySide6 and NumPy are all cross-platform. bootstrap.py (interpreter paths)
is the only other platform-aware file, and it now handles Linux and Windows.
| OS | Status | Notes |
|---|---|---|
| macOS | verified | everything in this document was measured here |
| Linux | expected to work, not tested | needs two setup steps, below |
| Windows | plausible, not tested | libusb needs a WinUSB binding; the vendor SDK exists there anyway |
Two things differ from macOS and both bite immediately if missed:
- udev rules. macOS leaves the device unclaimed and readable; Linux does not. Aravis
ships
aravis.rules— install it, or every command needssudo. - usbfs buffer size. The kernel default of 16 MB is too small for USB3 Vision
streaming and shows up as dropped frames or a stalled stream:
echo 1000 | sudo tee /sys/module/usbcore/parameters/usbfs_memory_mb
Install the stack with your package manager instead of Homebrew
(gir1.2-aravis-0.8 python3-gi on Debian/Ubuntu, aravis python3-gobject on Fedora).
Windows binds its own driver to the camera, so libusb cannot claim it until the device is rebound to WinUSB (Zadig or similar). Aravis itself runs on Windows, so the rest should follow — but this has not been tried, and IDS peak is available on Windows anyway, so there is less reason to.
# 1) camera stack
brew install aravis ffmpeg pygobject3
# 2) Qt and numpy, for the GUI and the analysis features
python3 -m venv --system-site-packages .venv
.venv/bin/pip install PySide6-Essentials numpyAravis' Python bindings (gi) live only in Homebrew's Python; PySide6 lives only in the
venv. A venv created with --system-site-packages inherits gi, so both meet in one
interpreter. bootstrap.py re-executes into whichever interpreter has what is needed, so
just run the scripts.
PySide6 comes from a venv rather than
brew install pyqt: the brew route pulls in qtwebengine and needs 1–2 GB, while the venv with abi3 wheels needs about 370 MB installed (~120 MB of wheels to download).
./camrec.py list # is the camera visible?
./camrec.py check # which features can this camera drive?./camgui.py # GUI
./camrec.py check # compatibility report
./camrec.py background --seconds 3 # background + stability verdict
./camrec.py autoexpose --target 50 --save # set exposure/gain against the beam
./camrec.py watch -o run.mkv --pre-frames 60 # auto-record when light appears
./camrec.py record -o run.mkv --codec ffv1 -d 60 # 12-bit lossless capture./camgui.pyLive view at 499.7 fps · 400×300 Mono12 · 90 % duty · 119.9 MB/s. The green toolbar button
means the stream is running, and the histogram below the preview shows the two-lobe
distribution of a lit subject against a darker background. Bottom right, USB 500 MB/s
confirms a SuperSpeed link — it turns amber if the cable ever trains down to USB 2.0.
Live view and a brightness histogram on the left, a settings panel ordered the way the decisions are made on the right, and the recording box pinned bottom-right. Everything fits on one screen without scrolling.
The UI language is selectable from the toolbar (English by default, Korean available); the choice is remembered between runs.
① Pixel format · ② Sensor area what counts as one frame
③ Frame rate · takes priority nothing below is allowed to move it
④ Duty · Exposure brightness (frame rate held)
⑤ Gain · Image gain · gamma · black level
Other trigger · flip · bandwidth
Recording manual recording / light-triggered recording
| Where | Colour | Meaning |
|---|---|---|
| Toolbar stream button | green | acquiring |
| grey | stopped | |
| Record button | red | recording |
| amber | armed (light watch) | |
| grey | idle | |
| Status bar stats | grey | normal |
| amber | more than 1 % of pixels clipped at full scale | |
| red | frames lost | |
| red | camera disconnected | |
| Link speed | grey | USB 500 MB/s — SuperSpeed |
| amber | USB 2.0 link — trained down, see below |
|
| Histogram | amber/red bars | clipping at 0 or full scale |
| Temperature | amber | above 55 °C |
- Live view — acquisition runs at full speed while the display is capped at 30 fps, so the UI stays responsive even while recording at 600 fps.
- ROI, pixel format, trigger, flip (both axes), factory reset, loading a settings file — this camera write-protects those registers during acquisition, so the GUI stops and restarts the stream around the change (automatic, ~0.1 s). Blocked while recording.
- Toolbar — start/stop stream · snapshot (
Ctrl+S) · fire trigger · collect background · beam exposure calibration · save/load settings · reconnect · reset · UI language
Pull the USB cable and the GUI stays up, waits for the camera and puts the session back when it returns. Nothing needs restarting.
| Stage | What you see |
|---|---|
| Cable pulled | preview reads Disconnected, status bar turns red, the settings panel greys out |
| Searching | Searching for the camera... (12 s), polled every 2 s for two minutes |
| Back | Camera found again — settings restored, live view resumes |
| Gave up | Auto-reconnect stopped — press Reconnect |
🔌 Reconnect in the toolbar forces a rescan at any time except during a recording — useful if the camera was never detected at the start, or if auto-reconnect timed out.
Four things notice the loss independently, and whichever fires first wins: Aravis'
control-lost signal, three seconds of stream silence, a failed stream start, and a failed
register read from the twice-a-second refresh — the last being the only one that catches a
disconnect while the stream is stopped. Silence alone is not treated as proof — under an
external trigger a quiet stream is normal — so the control channel is queried before anything
is declared lost.
Settings are restored from a copy taken while the camera was still answering (refreshed every 2 s). Once the device is gone every register read fails, so the snapshot has to predate the disconnect. Pixel format, ROI, binning, fps, exposure, gain and trigger all come back.
A recording in progress is closed cleanly, not truncated, and the GUI reports how many frames made it to disk.
The camera is found again by device id, falling back to serial number. The id embeds a device GUID rather than a bus location — verified by moving the camera to a different port and getting the identical id back — so replugging into a different port still finds the same camera.
The camera's LED is green on a SuperSpeed link and orange when it has trained down to USB 2.0. This is worth taking seriously because nothing else reports it: no error, no dropped buffers, no failed reads. The camera simply delivers fewer frames than the frame rate on screen claims.
Measured on the same camera and cable, moved between ports:
| SuperSpeed | USB 2.0 fallback | |
|---|---|---|
DeviceLinkSpeed |
500 MB/s | 60 MB/s |
| Mono12 400×300 set to 499.89 fps | 497.61 fps | 182.66 fps |
| Bandwidth | 119.4 MB/s | 43.8 MB/s |
| Bad buffers | 0 | 0 |
Note the last row. The degraded run reported zero errors and zero lost frames while delivering a third of the frame rate.
DeviceLinkThroughputLimit is no help either — it keeps reporting whatever it was configured
to (400 MB/s here) regardless of what the cable actually trained to. DeviceLinkSpeed is the
only feature that tells the truth, so that is what the GUI checks, on every connect and
reconnect. Below 200 MB/s it shows an amber USB 2.0 link in the status bar and explains what
the current settings will really deliver.
A USB3-capable port is not a guarantee. SuperSpeed uses two differential pairs that USB 2.0 does not, and the spec says to fall back silently rather than fail when they cannot be trained. Common causes: a USB 2.0-only cable or adapter, a USB3 Micro-B plug seated in only the USB 2.0 half of the connector, or a cable too long or too poor to train reliably.
| Command | Description |
|---|---|
list |
connected cameras |
check |
which features of this program the camera can drive |
info |
current settings (ROI, exposure, gain, fps, duty, bandwidth, temperature) |
features [pattern] [--values] |
GenICam feature tree |
get NAME... |
read values and ranges |
set [options] |
change settings only |
snap [-o FILE] |
still image |
record [-o FILE] |
video recording |
background |
collect a background + stability verdict |
watch |
record automatically when light appears (with pre-roll) |
autoexpose |
set exposure/gain so the peak does not clip |
bench [-n N] |
measure the achievable frame rate |
config [--save/--apply] |
settings file |
preset [--load/--save NAME] |
camera-internal UserSet |
reset |
restore factory defaults |
--device ID which camera (default: the first; ids come from `list`)
-v, --verbose print each setting as it is applied
--config PATH / --no-config settings file (see Settings file)
--outdir DIR output base folder (see Output folders)
--exposure US|auto|once|off exposure in us (`off` turns ExposureAuto off).
This camera: 11.7 – 1,927,000; `info` prints yours
--gain X|auto|once|off analogue gain 1.0 – 15.85 (`off` turns GainAuto off)
--fps F|max frame rate <- takes priority
--duty PCT|max shutter duty % (exposure derived from the frame rate)
--roi WxH+X+Y e.g. --roi 816x200+0+200
--width/--height/--offset-x/--offset-y/--full-frame
--binning N / --decimation N 1–8
--pixel-format FMT Mono8 | Mono10 | Mono12 | Mono10p | Mono12p
--gamma G / --black-level B / --reverse-x / --reverse-y
--throughput MBPS USB bandwidth cap (16–400)
--trigger off|software|line0..line3
--set NAME=VALUE any GenICam feature (repeatable)
--set reaches every feature not listed above. Find names with features.
The exposure cannot be longer than the frame period, so the frame rate will not rise unless the exposure comes down. Measured at the full 816 × 624 frame:
| Exposure | Max fps |
|---|---|
| 15,000 µs | 66.4 |
| 5,000 µs | 197.3 |
| 2,000 µs | 483.3 |
| ≤ 1,000 µs | 608.5 (sensor readout limit) |
It is not a bandwidth limit — DeviceLinkThroughputLimit is already at its 400 MB/s
maximum and 608 fps only uses 309 MB/s.
Changing duty, exposure or gain never moves the frame rate. If an exposure would exceed the frame period, the exposure is what gets clamped:
Exposure 50,000 us would break 200.04 fps, so it is limited to 4,930 us
(frame rate wins; lower the fps if you need it brighter)
Raising the frame rate does the reverse: the exposure shortens automatically, preserving the duty. Whatever changed is always reported.
Width does not affect the frame rate; only height enters the readout time.
./camrec.py bench -n 500 --roi 816x200 --exposure 200 --fps max
# -> measured 1397.44 fps (228.1 MB/s)Pixel format, ROI and fps from a previous run survive as long as the camera has power.
If things look wrong, ./camrec.py reset.
The fraction of each frame the global shutter is open. duty = exposure × fps, verified
against the sensor's own clock counters (SensorExposureTimeClocks /
SensorFrameTimeClocks) to within 0.03 %.
./camrec.py info # current / ceiling
./camrec.py set --fps 300 --duty 90 # 90 % open at 300 fps
./camrec.py set --fps 600 --duty max # physical maximum100 % is impossible in principle. Every frame has readout blanking, and on the U3-31A0SE-M-GL it measures as a constant independent of ROI, depending only on pixel format — Mono8 68.3 µs, Mono12 59.7 µs. (Other models differ, so the program measures it every time.)
Therefore max duty = 1 − blanking × fps:
| fps | Max duty (Mono8, 816 × 624) |
|---|---|
| 5 | 99.97 % |
| 60 | 99.59 % |
| 300 | 97.95 % |
| 608.5 (readout limit) | 95.84 % |
Shrinking the ROI to go faster lowers the ceiling further — 90.5 % at 816×200 @ 1397 fps, 86.4 % at 816×100 @ 1993 fps. Speed and collected light trade off directly.
A 90 % duty is only reachable up to fps ≤ (1 − 0.90) / 68.3 µs ≈ 1464 Hz.
The default is free-run — the camera captures continuously at
AcquisitionFrameRate. In trigger mode it captures one frame per incoming signal
(beam-pulse sync, strobes, multi-camera sync).
./camrec.py set --trigger software # host fires over USB
./camrec.py set --trigger line0 # opto-isolated input on the 8-pin Hirose
./camrec.py set --trigger off # back to free-runMeasured: 0 frames in 2 s with no triggers, 5 frames for 5 triggers. Software
triggers sustain 250 fps, but jitter is at the mercy of the USB round trip — use a
hardware trigger for precise synchronisation. This camera supports RisingEdge only, and
TriggerDelay covers 0–16.7 s.
The GUI is manual by default — one frame per ⚡ press. Tick Auto-fire for
continuous.
- Writing
AcquisitionFrameRatesilently disarms the trigger (exposure, gain and gamma do not). That is whyapply()always arms the trigger last. TriggerModeis scoped byTriggerSelector, so reading the state means walking the selectors — andTriggerSoftwarefires whichever selector is current. Polling the state during acquisition sends triggers to the wrong place: a 4 Hz poll alone dropped software-triggered capture from 250 fps to 37 fps. The trigger state is therefore cached.- Duty cannot be computed in trigger mode. The trigger interval sets the frame period,
so
AcquisitionFrameRateis meaningless. Firing every 100 ms gives a real 8.92 fps (4.46 % duty) while the register still reads 196 fps (98.21 %).infoprints the formula instead of a number, and--dutyis refused.
If the first pulse in a dark room clips, that measurement is gone. Brightness is unknown before the beam arrives, so this routine always starts at the minimum exposure and converges upward — it can never walk past an unknown saturation point unnoticed.
./camrec.py autoexpose # hold duty >= 90 %, keep the current fps
./camrec.py autoexpose --fps-target max # fastest fps within the gain budget
./camrec.py autoexpose --fps-target 250 # fix the fps, use gain to reach target
./camrec.py autoexpose --max-gain 4 # cap the gain (SNR first)
./camrec.py autoexpose --min-duty 0 # exposure only, fps and gain fixed [1] 11.7 us peak 0 ( 0.0%) clipped 0 -> no signal, x8
[3] 754.2 us peak 9 ( 3.5%) clipped 0 -> x8.00
[5] 10,257.3 us peak 128 (50.2%) clipped 0 -> target reached
With the duty pinned, peak = k · duty · (gain / fps), so gain and frame rate are
strictly proportional. Verified on hardware: 88 fps → 1.01x, 608.5 fps → 7.00x
(ratio 6.92 vs 6.93).
| fps | Gain required | Exposure |
|---|---|---|
| 120 | 1.36x | 7,500 µs |
| 240 | 2.72x | 3,750 µs |
| 608 | 6.89x | 1,480 µs |
Gain adds no photons, so the price of a higher frame rate is exactly the SNR you give up. Where to sit on that line is the experiment's call, so the command shows both ends and places you at the point you asked for.
Options : gain 1.0x -> 91.4 fps | gain 8x -> 608.5 fps
- Peak = the Nth-largest pixel (default 10). The true maximum lets one hot pixel drag the whole frame into underexposure. A percentile is not used either: a beam spot can be smaller than 0.1 % of the sensor, and a percentile sitting below the spot would cause overexposure.
- The dark baseline is always measured at the minimum exposure. A bright
--startwould read the beam itself as "dark" and push the threshold above full scale. - Saturation is judged before weak-signal. A clipped frame carries no usable ratio, so the routine backs off to a quarter of the exposure and re-measures.
- A point predicted to clip is never applied. When the duty floor and the beam brightness conflict, the duty floor gives way and the reason (ND filter / fps change) is reported.
- Pulsed beams: each exposure is watched for 2 s / 40 frames and the brightest frame is used.
- In trigger mode the trigger rate is measured at the minimum exposure. Measured at a long exposure you get the exposure-limited rate instead, and designing a duty around that produces an exposure longer than the real trigger interval — at which point the stream goes silent.
Records a few seconds of the static scene and leaves a background clip, a reference
image and a stability verdict. watch takes its baseline from here, so an unstable
background either false-triggers or forces a threshold so high that real events are missed.
./camrec.py background # 3 s
./camrec.py background --seconds 10
./camrec.py background --no-video # statistics onlyThe mkv is never read back. Each frame goes two ways from the same buffer: into memory (analysis + per-pixel median → PNG) and into the mkv sink. The reference PNG is not one frame but the per-pixel median of all of them, so noise is suppressed and anything that merely passed through is removed. The sensor's bit depth is preserved (Mono12 → 16-bit PNG).
| Metric | Warning threshold |
|---|---|
| Coefficient of variation of frame mean | 0.5 % wobble / 2 % severe |
| Histogram shift between frames | 5 % |
| Drift (linear trend) | 1 % |
| Sustained periodic component | 0.3 % amplitude |
| Outlier frames (beyond 6 MAD) | any |
The verdict is stable / caution / unstable; unstable exits with status 1.
Only sustained periodicity counts as flicker. The window is split in half and the same frequency must appear in both at comparable amplitude. Without that check a single flash spread energy across low frequencies and was reported as "2.3 Hz periodic", which it was not. Anything below 2 Hz is excluded as well — that is drift, and drift is reported separately.
Near mains frequencies (50/60/100/120 Hz) it is called out as lighting flicker with advice to set the exposure to an integer multiple of the mains period. In practice this camera picked up a 118.9 Hz ripple at 0.06 % amplitude.
Since the threshold has to clear whatever the background does on its own, a watch
threshold is suggested along with its reasoning:
-> Set the watch threshold to --threshold 47.8 or higher — periodic variation dominates
(peak spread 0.39%, periodic 15.59%, mean wobble 21.25%)
Learns the static background, then starts recording the instant bright light appears. Each
event goes to its own file (img/event_<time>_001.mkv, event_<time>_002.mkv, …;
with -o run.mkv: run_001.mkv, run_002.mkv, …) and the detector re-arms
automatically.
./camrec.py watch # defaults
./camrec.py watch -o run.mkv --pre-frames 60
./camrec.py watch --threshold 3 --events 20In the GUI: the Light watch checkbox plus the pre-roll spin box in the recording panel.
By the time a bright frame has been detected, that frame is already in the past.
Starting the recording after detection loses the very first moment the beam arrives. While
armed, frames are held in a ring buffer; on trigger the buffer is flushed into the file
first and the live frames follow. Measured with --pre-frames 20:
frame 0–19 : 4.57 <- background, 20 frames
frame 20 : 251.20 <- the exact frame the light appeared
| Stage | What happens |
|---|---|
| Learn | 40 frames collected; the 90th percentile peak becomes the background |
| Armed | threshold = background + 5 % (default); ring buffer maintained |
| Trigger | peak crosses the threshold → pre-roll + current frame onward |
| Stop | at least 2 s after the trigger and 0.5 s after the signal is gone |
| Re-arm | 1 s cooldown (prevents re-triggering on the tail) |
The threshold uses the 90th percentile rather than the maximum so that one noisy frame
during learning cannot raise the bar and swallow real events. Ctrl-C still finalises an
event in progress.
The extension selects the codec (override with --codec).
| Extension | Default codec | Use |
|---|---|---|
.mp4 / .mov |
h264 |
quick review, small files |
.mkv |
ffv1 |
lossless, keeps 12 bit intact (gray12le) |
.avi |
rawvideo |
uncompressed container |
.raw / .bin |
raw |
headerless frame sequence + .json |
.png / .tiff |
image sequence | out.png → out_000001.png … |
Also available: hevc, h264_vt / hevc_vt (VideoToolbox hardware). If the encoder
cannot keep up at high frame rates, use .raw — measured, the raw dump sustained
308.4 MB/s to disk at 605.7 fps with no losses.
Video containers themselves hold almost nothing (an mp4 carries the frame rate and the
encoder name). So every record, watch and background run gets a <output>.json sidecar, unless
--no-metadata is given (--no-timestamps drops the CSV the same way). snap --codec raw
is the exception: it writes no sidecar.
| Location | Contents |
|---|---|
<output>.json |
everything — camera, ROI, exposure, gain, gamma, duty, trigger, temperature, measured fps, drop counts |
<output>.timestamps.csv |
per-frame camera and host timestamps |
| any ffmpeg container | comment = one-line summary, title = camrec |
mkv CAMREC_JSON |
the capture settings as JSON, written when the encoder starts (Matroska preserves arbitrary tags). Measured fps and drop counts are added afterwards and exist only in the .json sidecar |
Both the requested and the achieved values are recorded. timing.fps_setting is what was
asked for; output.measured_fps and output.measured_shutter_duty_pct are what actually
happened. If a 534 fps request delivers 380 fps because of bandwidth, the duty is 19.00 %,
not the 26.70 % implied by the setting — trusting the setting alone gives the wrong
answer.
camera_timestamp_ns comes from the camera's own clock and is immune to host jitter. Lost
frames show up as a jump in delta_us.
import json, numpy as np
meta = json.load(open("burst.raw.json"))
layout = meta["raw_layout"]
dtype = np.uint8 if layout["pixel_format"] == "Mono8" else np.uint16
frames = np.fromfile("burst.raw", dtype=dtype).reshape(
layout["frame_count"], layout["height"], layout["width"])Mono10/Mono12 sit LSB-aligned in a 16-bit container (0–1023 / 0–4095). Mono10p and
Mono12p are bit-packed, so they can only be written as .raw and need unpacking.
Store settings in camrec.json or config.json and both the CLI and the GUI load them
automatically. config.json is gitignored because it holds your own operating point;
config.example.json ships as a starting point:
cp config.example.json config.json./camrec.py config --save # save current settings (updates the file already in use)
./camrec.py config # show contents
./camrec.py --config lowlight.json record …
./camrec.py --no-config record …Precedence: settings file → command-line options. --fps 60 wins even if the file says
fps: 150. Auto modes are stored as "auto" rather than the number they happened to land
on, so reloading restores the intent. Features the camera lacks (binning, for instance) are
skipped with a log line, so a settings file carried to another model does not break.
This is separate from the camera-internal UserSet presets (preset); this one survives a
power cycle and can be version-controlled.
bkg/ background collection (background_*.mkv, *_reference.png, *.json)
img/ snapshots, recordings, watch events
./camrec.py record # -> img/rec_<time>.mp4
./camrec.py record -o run1.mkv # -> img/run1.mkv
./camrec.py --outdir data record # -> data/img/rec_<time>.mp4
./camrec.py record -o shots/a.mkv # -> shots/a.mkv (an explicit path is left alone)All of the following was measured directly on one IDS U3-31A0SE-M-GL Rev.1.2.
| Setting | Result |
|---|---|
| 816×624 Mono8, 500 µs exposure | 608.53 fps / 309.9 MB/s, 1500 frames, no losses |
| 816×200 Mono8, 200 µs exposure | 1397.44 fps / 228.1 MB/s |
| 816×624 Mono12 → FFV1 mkv | 60 fps for 2 s (120 frames), 12 bit preserved, 122.2 MB → 46 MB |
| 816×624 Mono8 → .raw | 608 fps, 101,836,800 B (byte-exact) |
| Software trigger | 250 fps fired, zero timeouts |
Camera-side failures, underruns and drops were zero in every case.
IDS splits into two families.
| Family | Protocol | Works |
|---|---|---|
U3- (uEye+ — CP/XCP/SE/XLE) |
USB3 Vision + GenICam | yes |
UI- (classic uEye) |
IDS proprietary transport layer | no |
There is no vendor-specific code anywhere, so any vendor that honours the U3V standard will attach. When a new camera arrives, do not guess:
./camrec.py checkIt reports the transport layer, 5 required features, 17 optional ones, and per pixel format whether it is video-encodable / raw-only / analysable / unsupported, then gives a verdict.
- Colour models can only record. Preview,
autoexpose,watchandbackgroundareMono8/10/12only and do not work with Bayer formats. Mono10p/Mono12p(bit-packed) can only be written as.raw. ffmpeg has no matching layout, so no video container — and no analysis either.autoexposeis a calibration, not a live tracker. It fixes the exposure from the first pulses and then records; if beam intensity changes a lot mid-run, run it again.- Hardware triggering (
line0) has not been verified. With no signal source available, only the software-trigger path was exercised. The code difference is just theTriggerSourcevalue, but check it once with real wiring. - Only one process can hold the camera. Running the CLI while the GUI is open gives
LIBUSB_ERROR_ACCESS. is_locked()cannot be trusted on this camera —ExposureTimereports locked=True even though it writes fine, andTriggerModereports locked=False while being write-protected. The code therefore never decides from it and surfaces failures instead.- Blanking cannot be measured during acquisition, so it is estimated. With no cached value and the readout limit in effect the estimate does not hold, so a cached value from another format is used, or 0 (making the ceiling slightly permissive).
- The
watchCLI path was not verified against a real event. Only one process can hold the camera, so there was no way to produce light while the CLI was running. The detection, pre-roll and re-arm logic were verified at module level against the real camera, and the GUI path was verified end to end. - Everything was verified on one U3-31A0SE-M-GL Rev.1.2. Statements about other models rest on standards compliance and a code audit, not on measurement.
| Symptom | Cause / fix |
|---|---|
Cannot open camera |
another process holds it (GUI, another camrec) |
No camera found |
check USB: ioreg -p IOUSB -w0 -l | grep -i "USB Product Name" |
| Image is black | --fps max together with --exposure auto. Exposure cannot exceed the frame period — lower --fps |
| Underruns / failures | lower --throughput 300 or raise --buffers 128 |
encoder fell behind, dropped N frames |
--queue 512 or record to .raw |
| Settings look wrong | ./camrec.py reset |
| Packed-format encoding error | --pixel-format Mono8|Mono10|Mono12 or .raw |
write-protect error |
that setting cannot change during acquisition; set it with the stream stopped |
camrec.py CLI (argparse subcommands)
camgui.py Qt GUI (PySide6)
bootstrap.py re-executes into an interpreter that has the bindings
u3vcam/
camera.py Aravis wrapper: feature access, option application, streaming
record.py sinks (ffmpeg/raw) + FrameWriter + acquisition loop
beam.py beam exposure calibration (convergence, operating point)
background.py background collection + stability analysis
watch.py event detection + pre-roll ring buffer
config.py settings file I/O
i18n.py GUI translations
The CLI and the GUI share u3vcam. The recording path is common too (FrameWriter +
Sink), so behaviour verified on one side holds on the other.
It also works as a library.
from pathlib import Path
from u3vcam import Camera, CameraOptions, make_sink, record
cam = Camera()
cam.apply(CameraOptions(pixel_format="Mono12", full_frame=True, fps=200, duty=90))
stats = record(cam, make_sink(Path("out.mkv"), "ffv1"), duration=5)
print(stats.measured_fps, stats.failures)IDS Imaging Development Systems GmbH · order no. 1010392 · in production (product page)
| Manufacturer | IDS Imaging Development Systems GmbH |
| Model | U3-31A0SE-M-GL Rev.1.2 (reports as U3-31AxSE-M, family U3-SE) |
| Sensor | Sony IMX426LLJ-C, CMOS mono, global shutter |
| Resolution | 816 × 624 (0.51 MP), 17:13 |
| Pixel | 9 µm, optical 1/1.7" (7.344 × 5.616 mm) |
| ADC | 12 bit |
| Max fps | 738 (spec) / 608.5 (measured, full frame) |
| Exposure | 0.0117 ms – 1927 ms (long exposure 90 s) |
| Gain | 16× (datasheet) / 15.85× (measured feature maximum) |
| Interface | USB 3.2 Type-C, screw-lock, 5 Gbps |
| I/O | 8-pin Hirose, opto-isolated trigger in / flash out, 2× GPIO |
| Mount | C-mount, 34×44×47 mm, 110 g, IP30 |
| Power | 1.8 – 4.6 W (USB bus powered) |
| On board | 128 MB image memory, sequencer, chunks, LUT, gamma, denoiser |
MIT — see LICENSE. Aravis (LGPL-2.1+) and PySide6 (LGPL-3.0) are used as separate, dynamically linked components and keep their own terms.
