uvc_avf
PyPI:
pip install macuvc(this distribution) orpip install uvc-avf, the same code under its repository name. The import name isuvc_avfeither way.
A sudo-free stand-in for pyuvc (import uvc) on macOS.
- Frames come from AVFoundation, through a small Swift helper process that writes each frame's luma plane into a shared-memory ring.
- UVC controls (exposure, gain, auto-exposure mode, extension units, ...) are class requests sent with libusb on the camera's default control pipe, without claiming an interface.
Neither part needs root. The module mirrors the pyuvc calls a typical capture loop makes, so pyuvc code can switch backends with one import:
import os
if os.geteuid() == 0:
import uvc # pyuvc: needs sudo on macOS
else:
import uvc_avf as uvc # this package: no sudo
Scope: verified with one camera (Arducam OV9281 USB Camera behind a Sonix bridge, 0c45:6366) on
one Mac, see Measured results. Frames are luma (grayscale) only.
Why pyuvc needs sudo on macOS
libuvc (which pyuvc wraps) streams by claiming the camera's VideoStreaming interface through libusb. macOS's own UVC driver holds that interface. libusb can only take it by "capturing" the device through a re-enumeration, and IOKit allows that only for root or for a process that has Apple's VM device-access entitlement. No user-space workaround exists for streaming: without root, the frames have to come from macOS, which means AVFoundation.
Controls are a different matter. They are class requests on the default control pipe (endpoint 0), and macOS lets a normal user send those through libusb without claiming any interface, even while AVFoundation streams the same camera. uvc_avf combines the two paths.
What works without root
Verified 2026-09-26/27 with tools/nosudo_probe.py, run as a normal user, once with the camera
idle and again while an AVFoundation session streamed it (setup under
Measured results):
| Operation (libusb, no interface claimed) | Result |
|---|---|
libusb_open |
succeeds |
libusb_kernel_driver_active on interfaces 0 and 1 |
True for both |
libusb_claim_interface(0) |
fails with LIBUSB_ERROR_ACCESS |
| Standard descriptor reads (e.g. the product string) | work |
UVC GET_LEN, GET_INFO, GET_CUR, GET_MIN, GET_MAX, SET_CUR on the camera terminal, processing unit and extension units |
work, idle and while streaming |
Exposure write (SET_CUR) with read-back |
works, idle and while streaming |
| Vendor extension-unit register read (Sonix ASIC id) | works, idle and while streaming |
While macOS streamed, the streaming interface sat on alternate setting 6, and the committed mode
(VS_COMMIT_CONTROL GET_CUR) was MJPEG 800x600 @ 120 fps with 3072 bytes per transfer.
Design
camera --USB--> macOS UVC driver --> AVFoundation --> uvc_avf_helper (Swift, child process)
^ | copies the Y plane into a ring slot
| | prints "frame SLOT SEQ" on stdout
| v
| ring file, mmap'ed by both processes
| |
| v
+-- UVC requests on endpoint 0 <-- uvc_avf (Python): Capture.controls, Capture.get_frame()
(libusb, no interface claimed)
Streaming path
Capture(uid)opens the device with libusb, parses the UVC descriptors (modes, unit ids, control bitmaps) and buildscap.controls. It does not stream.- The first
get_frame()builds the helper if needed (swiftc -O, next to the module, rebuilt whenuvc_avf_helper.swiftis newer than the binary), creates a temporary directory (uvc_avf_*under$TMPDIR) holding the ring fileframes, and starts the helper. It waits up to 15 s (Capture.READY_TIMEOUT) for the helper'sreadyline, then maps the file read-only. - The helper picks the AVFoundation device (see Device matching) and the
AVFoundation format with the exact size and a frame-rate range whose maximum is within 1 fps of
the request, preferring
420vformats (the MJPEG modes). It requests420foutput, falling back to420v. It takeslockForConfiguration(), sets the format and both frame durations to the range's exactminFrameDuration, callsstartRunning(), unlocks, and checks that the session kept the format. - For each frame the capture callback copies the luma plane row by row into the next ring slot,
tightly packed (no row padding). For
420f/420vthat is plane 0; for2vuy/yuvsit takes every other byte. - Each
get_frame()reads the pendingframelines, keeps at mostcap.max_frame_lagof them (default 4; older ones are counted inframes_skipped), and copies the oldest remaining slot. stop_stream()/close()sendquiton the helper's stdin. The helper also exits on stdin EOF, so it does not outlive the Python process.
Helper protocol
uvc_avf_helper --shm PATH --width W --height H --fps F [--slots N]
[--unique-id ID] [--vidpid VVVVPPPP] [--device NAME] [--test-pattern]
Capture passes --slots 8, the computed AVFoundation uniqueID, the vendor and product id as
8 hex digits, and the USB product string.
| stdout line | Meaning |
|---|---|
ready {json} |
Streaming started. Keys: width, height, fps, fourcc (the AVFoundation format's subtype, e.g. 420v), slots, slot_size, header_size, slot_header, file_size, device, unique_id, matched_by (unique id, vid/pid, name, or test), pid. |
frame SLOT SEQ |
Frame number SEQ (1, 2, ...) is complete in slot SLOT = SEQ mod slots. |
warn ... |
Non-fatal problem; the module logs it as a warning. |
info ... |
Informational, e.g. info quit. |
error ... |
Fatal. The helper exits right after, and the module raises the text as StreamError. |
stdin: a quit line or EOF ends the helper.
Exit codes: 0 after quit/EOF; 1 ring-file error; 3 camera permission denied; 4 no matching
AVFoundation device; 5 no matching format, or the session replaced it; 6 cannot open the device,
add the input or output, or lock the configuration; 7 session runtime error or session stopped;
8 camera disconnected; 64 bad arguments (message on stderr).
--test-pattern skips AVFoundation completely, so it needs no camera and no permission. It
publishes a scrolling gradient at the requested rate, alternating bright and dark frames about
100 grey levels apart (odd SEQ bright), with fourcc TEST.
Ring layout
A plain file mapped by both processes. Values are little-endian.
File header (4096 bytes):
| Offset | Type | Field |
|---|---|---|
| 0 | u32 | magic 0x55564146 (UVAF) |
| 4 | u32 | layout version, 1 |
| 8 | u32 | width |
| 12 | u32 | height |
| 16 | u32 | number of slots |
| 20 | u32 | slot size in bytes |
| 24 | u32 | header size, 4096 |
| 28 | u32 | slot header size, 64 |
| 64 | u64 | SEQ of the latest published frame (= frames published) |
| 72 | u64 | frames dropped by macOS (captureOutput(_:didDrop:from:) calls) |
Slot k starts at 4096 + k * slot_size, where slot_size is 64 + width * height rounded up to
a multiple of 4096 (77824 bytes at 320x240, 483328 bytes at 800x600).
| Offset in slot | Type | Field |
|---|---|---|
| 0 | u64 | seq: the frame's SEQ; 0 while the helper is writing the slot |
| 8 | f64 | pts: the sample buffer's presentation time, seconds |
| 16 | f64 | t_callback: time in the helper's capture callback, taken right after the copy |
| 24 | u32 | width |
| 28 | u32 | height |
| 32 | u32 | pixel format of the delivered buffer (e.g. 420f; TEST in test-pattern mode) |
| 64 | u8[width * height] | luma plane, rows tightly packed |
Times are mach_absolute_time seconds, the same clock as Python's time.monotonic() on macOS
(time.get_clock_info("monotonic").implementation reports mach_absolute_time(); checked on
Python 3.9.6 and 3.11.12).
Write order in the helper (a seqlock): set seq to 0, memory barrier, copy the luma plane, write
the other slot fields, memory barrier, set seq to SEQ, update the file header, print
frame SLOT SEQ. The Python reader checks that seq equals SEQ before and after its copy. If
either check fails, the slot was being overwritten: the frame is discarded and counted in
frames_torn. The lag cap (4) is below the slot count (8), which keeps the reader away from slots
the helper is about to reuse.
Controls
- Requests:
bmRequestType0xA1 forGET_*and 0x21 forSET_CUR,wValue= selector << 8,wIndex= unit id << 8 | VideoControl interface number, 1000 ms timeout, on the libusb handle with no interface claimed. A lock serializes them. cap.controlsis pyuvc'sstandard_ctrl_unitstable (uvc_avf.standard_ctrl_units), filtered by thebmControlsbitmaps of the camera terminal and the processing unit in the descriptor. Controls the device does not advertise are left out.- Each
ControlreadsGET_INFOwhen it is created (info_bit_mask), plusGET_MIN,GET_MAX,GET_RESandGET_DEFfor whichever ofmin_val,max_val,stepanddef_valthe table leaves asNone.refresh()re-readsGET_INFOand fills in any limit that is stillNone. .valuereadsGET_CURand writesSET_CUR. Controls that share one request buffer (Pan/Tilt, White Balance blue/red) readGET_CURfirst and replace only their own field. Controls with a dictd_typealso accept the names, e.g.ctrl.value = "manual mode".- 4-byte controls, plus Brightness, Hue, Roll, Pan and Tilt, are decoded as signed; everything else is unsigned.
- A failed request is logged as a warning (
Could not get Value of .../Could not set Value of ...), as pyuvc does. The getter then returnsNone. Nothing is raised.
Device matching
The helper receives three keys and uses the first that matches an AVFoundation device:
- The AVFoundation
uniqueID, computed from libusb data as"0x"+ hex(locationID) + vendor id (4 hex digits) + product id (4 hex digits). locationID isbus << 24, with the USB port path in the following nibbles, one nibble per port, starting at bit 20. Example: bus 1, ports 1,1,0c45:6366gives locationID0x01110000and uniqueID0x11100000c456366. - A uniqueID ending in the vendor and product id (
0c456366). - A device name containing the USB product string.
The ready line and the INFO log (streaming 800x600 @ 120.0 (420v) from ..., matched by unique id)
say which key matched.
Requirements and install
- macOS. Tested on 26.4.1 (MacBook Air, Apple Silicon).
- Xcode command line tools for
swiftc(xcode-select --install). Tested with Swift 6.3.1. - Homebrew libusb:
brew install libusb. Tested with 1.0.29. The module loads/opt/homebrew/lib/libusb-1.0.dylib, then/usr/local/lib/libusb-1.0.dylib, thenlibusb-1.0.dylibfrom the default library search path. - Python 3.9 or newer, with numpy. The measurements used Python 3.11.
- Camera permission for the app that launches Python (see Gotchas).
pip install git+https://github.com/VimalMollyn/uvc-avf
# or, to work on it:
git clone https://github.com/VimalMollyn/uvc-avf
cd uvc-avf
pip install -e .
The helper is compiled on the first get_frame() into the installed package directory, next to
uvc_avf_helper.swift, so that directory must be writable. This is the case in a virtualenv or an
editable checkout. On the packaging machine swiftc -O took about 2.5 s. To build it ahead of time
(this does not touch the camera):
python -c "import uvc_avf; print(uvc_avf._helper_exe())"
Usage
import uvc_avf as uvc
dev = next(d for d in uvc.device_list() if "Arducam" in d["name"])
cap = uvc.Capture(dev["uid"])
cap.frame_size = (800, 600)
cap.frame_rate = 120
ctrls = {c.display_name: c for c in cap.controls}
ctrls["Auto Exposure Mode"].value = 1 # manual; needed before writing the exposure
ctrls["Absolute Exposure Time"].value = 50 # units of 0.0001 s
try:
first = cap.get_frame(timeout=10.0) # the first call also starts the stream
for _ in range(600):
frame = cap.get_frame(timeout=1.0) # TimeoutError, or uvc.StreamError if the helper died
img = frame.gray # (600, 800) uint8
finally:
cap.close()
pyuvc to uvc_avf
| pyuvc | uvc_avf | Notes |
|---|---|---|
uvc.device_list() |
same | List of dicts with name, manufacturer, serialNumber, idProduct, idVendor, device_address, bus_number, uid ("bus:address"). Every USB device with a UVC VideoControl interface. |
uvc.Device_List() |
same | update() re-reads the bus; cleanup() does nothing. |
uvc.Capture(uid) |
same | Opens the device with libusb (no claim), reads descriptors, builds controls. Extra keyword test_pattern=False. Does not stream yet. |
cap.available_modes, cap.all_modes |
same | CameraMode(width, height, fps, format_native, format_name, supported) from the UVC descriptors. format_native is the UVC frame index; format_name is "MJPEG", "YUYV", ...; fps is rounded to an integer. |
cap.frame_sizes, cap.frame_rates |
same | Sizes, largest first; rates available at the current size. |
cap.frame_mode |
same | Setting it while streaming stops the stream; the next get_frame() restarts it. |
cap.frame_size = (w, h) |
same | Keeps the current rate if the size offers it. Unknown size: CaptureError. |
cap.frame_rate = fps |
same | A mode within 1 fps of the request, MJPEG preferred; otherwise the highest rate at that size, with a warning. |
cap.bandwidth_factor |
accepted, unused | macOS decides the USB bandwidth. Default 2.0. |
cap.controls |
same | See Controls. |
cap.add_vendor_control(meta) |
same | Returns the new Control and appends it to cap.controls. |
cap.get_frame(timeout) |
same | timeout 0 (the default) or negative waits forever. Raises builtin TimeoutError, or StreamError when the helper dies. |
cap.get_frame_robust() |
same | Four attempts with a 1 s timeout, then StreamError("could not get a frame"). |
cap.stop_stream() |
same | Stops the helper; the next get_frame() starts a new one. |
cap.close() |
same | Stops the helper and closes the libusb handle. Also runs at interpreter exit and from __del__. |
frame.gray |
same | H x W uint8, a copy owned by the frame. |
frame.bgr, frame.img |
gray replicated | H x W x 3; no color. |
frame.yuv_buffer |
luma bytes only | Not a full YUV buffer. |
frame.jpeg_buffer |
raises AttributeError |
macOS decodes the MJPEG stream. |
frame.timestamp |
capture time | The buffer's presentation time, on the time.monotonic() clock. |
frame.index |
helper sequence number | See Differences. |
frame.width, frame.height, frame.data_fully_received |
same | data_fully_received is always True. |
uvc.get_time_monotonic() |
same | time.monotonic(). |
uvc.is_accessible(uid) |
always True |
|
uvc.InitError, OpenError, StreamError, DeviceNotFoundError, InsufficientBandwidthError |
same names | All derive from uvc_avf.CaptureError. InsufficientBandwidthError (a StreamError) is never raised. |
uvc_avf additions: cap.stats ({"frames", "dropped_by_macos", "skipped", "torn"}),
cap.max_frame_lag, cap.frames_skipped, cap.frames_torn, cap.avf_unique_id, cap.name,
frame.t_callback, frame.t_received, Capture.REOPEN_GAP (0.5 s), Capture.READY_TIMEOUT (15 s).
Differences from pyuvc
- Delivery order. Frames come oldest first, with the lag capped at
cap.max_frame_lagframes (default 4). libuvc always hands out the newest frame.cap.max_frame_lag = 1returns the most recently announced frame; values below 1 are not supported. - Child process. The stream lives in the helper process and starts on the first
get_frame(). That call first waits for the helper'sreadyline (up to 15 s); itstimeoutapplies after that. - Restart guard. The module waits so that at least 0.5 s pass between stopping a stream and
starting the next one (
Capture.REOPEN_GAP), because of a quirk of the tested bridge. frame.indexis the helper's sequence number: frames AVFoundation delivered to the helper, counting from 1 for each stream. Consecutive frames returned byget_frame()differ by 1 unless the reader skipped frames (max_frame_lag) or discarded a torn one. Frames lost before AVFoundation delivered them, including those macOS dropped (stats["dropped_by_macos"]), leave no gap. To detect those, compareframe.timestampdifferences with the frame period.- Signed values. 4-byte controls are signed 32-bit, like pyuvc (see Controls).
- No MJPEG buffers, and luma only.
- No bandwidth control.
bandwidth_factoris ignored.
Vendor (extension-unit) controls
add_vendor_control takes a pyuvc-style meta dict with the keys unit_id, display_name, unit,
control_id, offset, data_len, bit_mask, d_type, doc, buffer_len, min_val,
max_val, step, def_val. unit_id, display_name, control_id and data_len are required.
The defaults are unit="extension_unit", offset=0, bit_mask=0, buffer_len=data_len,
doc="" and d_type=int. The limits default to None and are then queried from the device.
Other keys raise TypeError.
meta = dict(unit_id=3, control_id=1, display_name="Vendor register", unit="extension_unit",
offset=0, data_len=4, buffer_len=4, bit_mask=1 << 0, d_type=int, doc="",
min_val=-(1 << 31), max_val=(1 << 31) - 1, step=1, def_val=0)
ctrl = cap.add_vendor_control(meta)
ctrl.value = request # SET_CUR, 4 bytes little-endian; pass a signed 32-bit int
reply = ctrl.value & 0xFFFFFFFF # GET_CUR, decoded as signed 32-bit
Example: on the tested camera, the Sonix bridge exposes ASIC register access as extension unit 3,
selector 1, 4 bytes. Writing the bytes [addr_lo, addr_hi, 0x00, 0xFF] (packed into a signed
32-bit int) requests a read. Byte 2 of the following GET_CUR holds the register value. The ASIC id
register 0x101F reads 0x83. Extension units are vendor-specific: do not send this to another
bridge.
Self-test
python -m uvc_avf --help
python -m uvc_avf --test-pattern # synthetic frames; no camera permission needed
python -m uvc_avf --reopen # real camera; run from Terminal.app
python -m uvc_avf --name "USB Camera" --size 640x480 --fps 100 --seconds 8
Apart from --help, every variant needs a UVC camera on the bus whose name contains --name
(default Arducam). Even --test-pattern opens that camera through libusb for its descriptors, modes and controls; only the
frames are synthetic. --size must be one of the camera's modes.
What it prints:
devices: [...],opened <uvc_avf.Capture ...> AVFoundation id 0x...,controls: [...],modes: [...],frame_mode: ....first frame after X s: index 1, WxH, mean M, ipc latency L ms. X starts at the firstget_frame()call, so it includes starting the helper (and compiling it the first time).N frames in S s -> F fps; non-consecutive: K; mean |pair diff| D; ipc latency p50 P ms max Q ms; stats {...}. F comes from frame timestamps. K countsindexgaps. D is the mean brightness difference between the two frames of each pair (about 100 with--test-pattern, whose frames alternate bright and dark). ipc latency ist_received - t_callback.- Real camera only:
exposure E -> set E+1 -> read back E+1 (min ... max ...). The exposure and the AE mode are restored afterwards. - Real camera only: a vendor-control check that runs only if the
sonix_i2cmodule of the project this package came from is importable. It is not part of this package; otherwise the line issonix_i2c not importable: vendor-control check skipped. - With
--reopen:closed; reopening, a second run of up to 3 s,closed.
The helper can also be run alone, with no camera, USB access or permission:
(sleep 2 | ./uvc_avf/uvc_avf_helper --shm "$TMPDIR/uvcavf_smoke" --width 320 --height 240 --fps 60 --test-pattern) | head -3
rm -f "$TMPDIR/uvcavf_smoke"
ready {"width":320,"height":240,"fps":60.0,"fourcc":"TEST","slots":8,"slot_size":77824,"header_size":4096,"slot_header":64,"file_size":626688,"device":"test-pattern","unique_id":"","matched_by":"test","pid":79624}
frame 1 1
frame 2 2
Probes
Both probes must run without sudo, from Terminal.app or another app with camera permission.
tools/avf_probe.swift: can AVFoundation stream this camera, and how fast?
swift tools/avf_probe.swift [name-substring] [width] [height] [fps] [seconds]
# defaults: Arducam 800 600 120 6; width 0 keeps the session's default mode
nosudo_probe.py compiles it to tools/avf_probe on first use. The output shows:
camera permission for this process: notDetermined|restricted|denied|authorized, andrequestAccess -> true|falseif it asked.devices: [...]andusing NAME (UNIQUEID). Compare UNIQUEID withCapture.avf_unique_id.format FOURCC WxH fps [...]for each AVFoundation format. On the tested camera the MJPEG modes appear as420vand the YUY2 modes asyuvs.data output pixel formats: [...]. On the tested camera:2vuy,yuvs,420v,420f,BGRA.requested ... at VALUE/TIMESCALE: the exact advertised frame duration, e.g.1000000/120000480.startRunning returned after X s, running=true; active format .... The line!! the session replaced the requested formatmeans the format did not survivestartRunning.frames N dropped D in S s -> F fps; interval p50 A ms max B ms, then eitherfirst frame: WxH FOURCC planes=P meanY M, T s after startorno frames in S s. With permission denied, AVFoundation still runs the session and this line readsno frames.
This probe requests 420v (video range), unlike the helper, so its meanY is not comparable with
frame.gray.mean().
tools/nosudo_probe.py: do controls work without root, idle and while macOS streams?
python3 tools/nosudo_probe.py [--vidpid VVVV:PPPP] [avfoundation-name-substring]
UVC_AVF_VIDPID=VVVV:PPPP python3 tools/nosudo_probe.py "Camera Name"
The defaults are 0c45:6366 and Arducam; --vidpid wins over UVC_AVF_VIDPID. The probe needs
only the standard library and libusb.
Phase 1, camera idle:
camera VVVV:PPPP at bus B addr A, running as uid U (not root, good).units: ...andstreaming formats (from the descriptor): ....libusb_open OK. The probe setsLIBUSB_DEBUG=2. A libusbUSBDeviceOpenwarning printed above this line means macOS holds the device object itself; control requests still work.interface N: kernel/system driver active = Truefor the VideoControl and VideoStreaming interfaces.== control requests, camera idle ==, oneOK valueorFAIL LIBUSB_ERROR_...line per request: product string, extension-unitGET_LEN/GET_INFO, Sonix ASIC id (expect0x83), AE mode, AE priority, exposureGET_CUR/GET_MIN/GET_MAX, exposureSET_CURwith read-back. The exposure and the AE mode are restored. The three extension-unit requests are Sonix-specific and run only for vendor id0c45; other cameras printskipped.VS interface N alt setting: 0 (idle)and the probe/commitGET_CURblocks.claim interface 0 without detaching: LIBUSB_ERROR_ACCESS, which is expected without root.
Phase 2, macOS streaming: avf_probe streams 800x600 @ 120 for 8 s. After 3.5 s the probe repeats
the control requests (while macOS streams) and reads the streaming state. The tested camera
reported alt setting: 6 (isoch pipe open, streaming) and a commit block of MJPEG 800x600 at
120 fps with 3072 B per transfer. If that attempt delivers no frames, the probe tries the session
default mode and then 640x480 @ 100, each in a fresh process 3 s apart. !! this process has no camera permission means TCC denied the launching app.
The run ends with a SUMMARY block and either verdict: sudo-free path WORKS: ... or
verdict: not proven yet (see the FAIL / !! lines above).
Gotchas
- Camera permission belongs to the launching app. macOS attributes camera access (TCC) to the
app that launched the process, not to Python or the helper. An app without
NSCameraUsageDescriptionin its Info.plist is denied silently. One example is the Claude Code desktop app. AVFoundation raises no error in that case: the session runs and delivers zero frames. Run from Terminal.app, which asks once. The helper checksAVCaptureDevice.authorizationStatusbefore starting and exits witherror camera permission denied for this process: ..., which the module raises asStreamError. On the first run the helper waits for the answer to the prompt. If the answer takes longer than 15 s,get_frame()raisesStreamError("camera helper did not start streaming within 15 s"); call it again after answering. - Hold the configuration lock across
startRunning(). The session applies its preset atstartRunning()and replaces the device'sactiveFormat. HoldinglockForConfiguration()acrossstartRunning()keeps the requested format; ffmpeg does the same. Re-applying the format after the start is a quick stop/start, which left the tested Sonix bridge delivering 0 frames. - Full range, not video range. The helper requests
420f(luma 0..255, what pyuvc's JPEG decode gives) and falls back to420v. Video range squeezes the picture into 16..235, about 14% less contrast. - Exact frame durations. Set the frame duration to the exact advertised
CMTime, e.g.1000000/120000480, not1/120, orsetActiveVideoMinFrameDurationthrows. - Quick restarts. Keep at least 0.5 s between stopping and starting a stream; the module
enforces this. Changing
frame_mode,frame_sizeorframe_ratewhile streaming is a stop plus a restart on the nextget_frame(). - First frame delay. The first frame arrived 0.56-0.85 s after start, and the very first
stream also compiles the helper. Give the first
get_frame()a generous timeout (the self-test uses 10 s). - One
Captureper camera. EachCapturestarts its own helper process and AVFoundation session. TwoCaptures on one camera were not tested. Close one before opening another; close followed by reopen was tested and works. - Exposure writes need manual mode. Set
Auto Exposure Modeto 1 (manual) before writingAbsolute Exposure Time(units of 0.0001 s). - Writable package directory. The helper binary is written next to the module on first use.
Measured results
Measured 2026-09-26/27 on this setup:
| Item | Value |
|---|---|
| Computer | MacBook Air (Apple Silicon), macOS 26.4.1 |
| Toolchain | Swift 6.3.1, Homebrew libusb 1.0.29, Python 3.11 |
| Camera | Arducam OV9281 USB Camera, Sonix bridge 0x0c45:0x6366 |
| Connection | USB 2.0 through an Apple USB-C multiport adapter |
| Measurement | Result |
|---|---|
| Frame rate | 800x600 at 118-122 fps, over runs of 5-8 s |
| Frames dropped by macOS | 0 |
| Frames skipped by the reader | 0 |
| Torn frames | 0 |
| First frame | 0.56-0.85 s after start |
Helper callback to Python (t_received - t_callback) |
p50 about 0.2 ms, max below 1 ms |
| Close and reopen | works |
| Controls while streaming | exposure write with read-back and vendor extension-unit reads work |
| Streaming interface while macOS streams | alternate setting 6; committed MJPEG 800x600 @ 120 fps, 3072 B per transfer |
Limitations
- macOS only. Verified with one camera, one bridge, one Mac and one macOS version (above). Other cameras and bridges are untested, and the restart guard and lock-across-start behaviour were established on the Sonix bridge.
- Measured end to end only in MJPEG 800x600 @ 120. The luma extraction for
2vuy/yuvsbuffers exists but was not measured. - Luma only.
bgr/imgreplicate the gray plane and any color is discarded; the tested sensor is monochrome. - A mode must exist in both the UVC descriptors and AVFoundation's format list (same size, maximum rate within 1 fps). Otherwise the helper fails with a list of what AVFoundation offers.
- No bandwidth control.
InsufficientBandwidthErroris never raised. is_accessible()always returnsTrue. Access problems surface asOpenErrororStreamError.- The uniqueID formula uses one nibble per port, so port numbers above 15 cannot be represented. The vendor/product and name fallbacks then apply. With two identical cameras, the vendor/product fallback takes the first AVFoundation device with that id, which may be the wrong one.
- Only the pyuvc surface listed above is provided.
License
MIT, see LICENSE. Copyright 2026 Vimal Mollyn.
Metadata
Release files for macuvc 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| macuvc-0.1.0.tar.gz | 41.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| macuvc-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 73.3 kB
Release files / macuvc-0.1.0.tar.gz
| Download URL | macuvc-0.1.0.tar.gz |
|---|---|
| Size | 41.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
80988658346fc6ca020d8982be686b922fde5702ae213d38f8f8b19e03a87052
|
|
BLAKE2b-256 checksum How to use checksums |
3c8761fc5ff22471d9b62e0514eac87acc213c4d1bd8824ad414aaf5cc12e42a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.13 {"installer":{"name":"uv","version":"0.9.13"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / macuvc-0.1.0-py3-none-any.whl
| Download URL | macuvc-0.1.0-py3-none-any.whl |
|---|---|
| Size | 31.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
82a7d07eed348265e163b3c4be1f6f83fac55187201e0539ea6d28d2a4df61a4
|
|
BLAKE2b-256 checksum How to use checksums |
5002907985403c811d38028f0e249b697b6f741a445b5f53d0949bb74ee66e75
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.13 {"installer":{"name":"uv","version":"0.9.13"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for macuvc 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.