Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

usb3-vision-camera-tool

English · 한국어

Control, record and analyse USB3 Vision industrial cameras on macOS without any vendor driver.

Why this exists

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.

Which cameras work

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.

Camera used

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.


Contents

  1. Why this exists · Which cameras work · Camera used
  2. Why no vendor driver is needed
  3. Other operating systems · Installation
  4. Quick start
  5. GUI
  6. Unplugging the camera
  7. CLI commands
  8. Camera physics you need to know
  9. Shutter duty cycle
  10. Triggering
  11. Beam exposure calibration (autoexpose)
  12. Background collection (background)
  13. Automatic recording (watch)
  14. Output formats and recorded metadata
  15. Settings file
  16. Output folders
  17. Measured performance
  18. Will other cameras work
  19. Limitations
  20. Troubleshooting
  21. Structure
  22. Camera specifications · Dependencies · License

Why no vendor driver is needed

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)

Other operating systems

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

Linux

Two things differ from macOS and both bite immediately if missed:

  1. udev rules. macOS leaves the device unclaimed and readable; Linux does not. Aravis ships aravis.rules — install it, or every command needs sudo.
  2. 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

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.


Installation

# 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 numpy

Aravis' 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?

Quick start

./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

GUI

./camgui.py

usb3-vision-camera-tool GUI

Live 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

State is carried by colour

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

Behaviour

  • 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

Unplugging the camera

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.

If the LED comes back orange

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.


CLI commands

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

Global options (accepted before or after the subcommand)

--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)

Camera options (shared by most commands)

--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.


Camera physics you need to know

1. Exposure time determines the maximum frame rate

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.

2. The frame rate takes priority

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.

3. A shorter height is much faster

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)

4. Settings persist in the camera

Pixel format, ROI and fps from a previous run survive as long as the camera has power. If things look wrong, ./camrec.py reset.


Shutter duty cycle

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 maximum

100 % 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.


Triggering

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-run

Measured: 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.

Three traps confirmed by measurement

  1. Writing AcquisitionFrameRate silently disarms the trigger (exposure, gain and gamma do not). That is why apply() always arms the trigger last.
  2. TriggerMode is scoped by TriggerSelector, so reading the state means walking the selectors — and TriggerSoftware fires 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.
  3. Duty cannot be computed in trigger mode. The trigger interval sets the frame period, so AcquisitionFrameRate is meaningless. Firing every 100 ms gives a real 8.92 fps (4.46 % duty) while the register still reads 196 fps (98.21 %). info prints the formula instead of a number, and --duty is refused.

Beam exposure calibration (autoexpose)

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

Why "balance"

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

Design points that matter

  • 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 --start would 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.

Background collection (background)

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 only

The 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%)

Automatic recording (watch)

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 20

In the GUI: the Light watch checkbox plus the pre-roll spin box in the recording panel.

The pre-roll is the whole point

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.


Output formats and recorded metadata

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.pngout_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.

Where the capture conditions are stored

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.

Reading a .raw file

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.


Settings file

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.


Output folders

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)

Measured performance

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.


Will other cameras work

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 check

It 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.


Limitations

  • Colour models can only record. Preview, autoexpose, watch and background are Mono8/10/12 only 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.
  • autoexpose is 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 the TriggerSource value, 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 cameraExposureTime reports locked=True even though it writes fine, and TriggerMode reports 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 watch CLI 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.

Troubleshooting

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

Structure

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)

Camera specifications (U3-31A0SE-M-GL Rev.1.2)

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

Dependencies

  • Aravis (LGPL-2.1+) — U3V/GenICam stack
  • FFmpeg — encoding
  • PySide6 (LGPL-3.0) — GUI
  • NumPy — analysis

License

MIT — see LICENSE. Aravis (LGPL-2.1+) and PySide6 (LGPL-3.0) are used as separate, dynamically linked components and keep their own terms.

About

USB3 Vision camera control, recording and image analysis on macOS — no vendor driver required

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages