2. Architecture & Design Overview¶
2.1 Two-Tier Architecture¶
duvc-ctl uses a layered design with two distinct Python APIs sitting on top of a shared C++ core. Both APIs access the same underlying DirectShow functionality but present different models to the user.
High-level overview¶
┌─────────────────────────────────────────────────┐
│ Python User Code │
├──────────────────────┬──────────────────────────┤
│ Pythonic API │ Result-Based API │
│ (CameraController) │ (open_camera, Result<T>)│
├──────────────────────┴──────────────────────────┤
│ pybind11 Python Bindings │
├─────────────────────────────────────────────────┤
│ C++ Core Library (duvc-ctl) │
│ (Device enumeration, property access, etc.) │
├─────────────────────────────────────────────────┤
│ Windows DirectShow & COM │
└─────────────────────────────────────────────────┘
Both Python APIs call the same C++ code. The difference is in how they handle device lifecycle, errors, and API design.
Pythonic API (Exception-based)¶
The Pythonic API, implemented in CameraController, wraps the C++ core and adds Python conventions:
Error handling: Exceptions (
DuvcErrorand subclasses) are raised for failures. Callers usetry-exceptblocks.Device management: Automatic device selection (first camera, or by name). Connection/disconnection handled via context manager (
withstatement).API style: Property-based access (
cam.brightness = 75) and method calls (cam.pan_relative(15)).State: The controller maintains internal state—the active device, connection handle, property cache—so the user doesn’t think about it.
Example:
import duvc_ctl as duvc
try:
with duvc.CameraController() as cam:
cam.brightness = 80
print(f"Brightness: {cam.brightness}")
except duvc.PropertyNotSupportedError as e:
print(f"Cannot set brightness: {e}")
When to use: Learning, scripting, simple applications where you want minimal boilerplate.
Result-Based API (Explicit error handling)¶
The Result-Based API, in the init.py module, provides low-level control without exceptions:
Error handling: Functions return
Result<T>types. Callers checkis_ok()oris_error()and access the value or error explicitly.Device management: Explicit. You pass a
Deviceor device index to each function.API style: Functional (
open_camera(device),camera.get(property)returnsResult<PropSetting>).State: Minimal and explicit. You own the Device and Camera objects.
Example:
import duvc_ctl as duvc
devices = duvc.list_devices()
if not devices:
print("No devices found")
else:
device = devices[0]
camera_result = duvc.open_camera(device)
if camera_result.is_ok():
camera = camera_result.value()
brightness_result = camera.get(duvc.VidProp.Brightness)
if brightness_result.is_ok():
setting = brightness_result.value()
print(f"Brightness: {setting.value}")
else:
print(f"Error: {brightness_result.error().description()}")
else:
print(f"Error: {camera_result.error().description()}")
When to use: Production code, detailed error recovery, systems that need to handle failures gracefully without exception overhead.
Design rationale¶
Two APIs instead of one?
The Pythonic API is easier to use for most cases. But production systems often need explicit error handling and minimal overhead. Rather than compromise with a single API, we provide both:
Beginners and scripts use the simpler Pythonic API.
Production code that needs fine-grained control uses the Result-Based API.
Both use the same C++ backend, so there’s no code duplication in the core logic.
Why exceptions vs. results?
Pythonic API (exceptions): Python developers expect exceptions. They’re idiomatic. Errors are detected early (at the point of failure) and propagate up without explicit checks.
Result-Based API (results): No exceptions mean no exception-throwing overhead. Each operation is explicit—you see exactly where errors are handled. This is valuable in production systems where you need to recover from failures gracefully.
C++ bindings layer (pybind11)¶
The bridge between Python and C++ is pybind11. It handles:
Type conversion (Python
int↔ C++int32_t, Pythonstr↔ C++std::string, etc.)Lifetime management (Python garbage collection ↔ C++ RAII and reference counting)
Exception translation (C++ exceptions to Python exceptions in the Pythonic API)
Module organization (separate namespaces for Pythonic and Result-based APIs)
The Result-Based API types (Result<T>, Device, PropSetting, etc.) are bound directly from C++ to Python. The Pythonic API (CameraController) is written entirely in Python and uses the Result-based bindings internally.
Thread safety¶
Both APIs are thread-safe at the module level, but individual devices are not thread-safe:
Multiple threads can safely call
list_devices(), open different cameras, etc.If two threads access the same camera (same
CameraControllerorCameraobject), data races can occur. Use a lock or ensure only one thread accesses a camera at a time.
Example of thread-safe usage (different cameras per thread):
import duvc_ctl as duvc
import threading
def camera_worker(camera_index):
with duvc.CameraController(camera_index) as cam:
cam.brightness = 75
print(f"Camera {camera_index}: brightness set")
devices = duvc.list_devices()
threads = [threading.Thread(target=camera_worker, args=(i,)) for i in range(len(devices))]
for t in threads:
t.start()
for t in threads:
t.join()
The C++ core uses a mutex internally to protect shared state (DirectShow device enumeration). Individual camera operations do not hold this mutex long; property get/set operations lock only the minimal necessary time to avoid contention.
Connection state machine¶
A camera connection goes through these states:
Closed: No active connection.
Opening: Connecting to the device (transitional).
Open: Connected; properties can be read and written.
Closing: Disconnecting (transitional).
The Pythonic API (CameraController) manages these transitions automatically via the context manager. You enter the with block → connection opens. You exit the with block → connection closes.
The Result-Based API requires you to manage state explicitly:
camera_result = duvc.open_camera(device) # Opens connection
if camera_result.is_ok():
camera = camera_result.value()
# Use camera...
# Connection closes when camera object is destroyed or explicitly closed
Error propagation¶
Pythonic API:
Errors are raised as exceptions. The exception propagates up the call stack until caught or reaches the top level (where Python prints a traceback).
Result-Based API:
Errors are wrapped in the Result type. You check
is_ok()oris_error()and access the error viaerror(). No automatic propagation; you decide how to handle each error.
Why this design?¶
Separation of concerns: Pythonic API handles convenience; Result-Based API handles control.
Flexibility: Users pick the API that fits their use case.
Performance: The Result-Based API avoids exception overhead if desired.
Production-ready: Both are suitable for production; the choice is about error handling philosophy.
2.2 API Comparison & Selection¶
This section compares both APIs head-to-head so you can choose the right one for your use case.
When to use each API¶
Scenario |
Recommended API |
Why |
|---|---|---|
Learning or prototyping |
Pythonic |
Fewer lines of code, quick iteration |
Quick script or automation |
Pythonic |
Automatic device selection, built-in defaults |
Production application with error handling |
Result-based |
Explicit error checks, no exceptions, full control |
High-frequency camera control loop |
Result-based |
Lower overhead; no exception throwing |
Handling multiple cameras concurrently |
Either |
Both are thread-safe; choose based on error handling preference |
Interactive or CLI application |
Pythonic |
Users expect exceptions; easier debugging |
Embedded or headless system |
Result-based |
Explicit error handling fits production patterns |
Real-time or time-critical code |
Result-based |
Predictable performance; no exception overhead |
Detailed feature comparison¶
Feature |
Pythonic |
Result-Based |
|---|---|---|
Device Selection |
Automatic (first device or by name) |
Manual (you pass Device) |
Error Handling |
Exceptions ( |
Result types ( |
Property Access |
Direct ( |
Method call ( |
Code Style |
Object-oriented |
Functional |
Boilerplate |
Minimal |
More verbose |
Exception Overhead |
Yes (thrown on error) |
No (checks Result) |
Error Details |
Exception message + type |
Error code + detailed description |
Device Lifecycle |
Context manager ( |
Manual (you control lifetime) |
State Management |
Automatic (maintained by controller) |
Manual (you own state) |
Learning Curve |
Beginner-friendly |
Moderate |
IDE Autocomplete |
Good (Python class) |
Good (types exported) |
Type Hints |
Yes (CameraController class) |
Yes (Result |
Device management lifecycle¶
Both APIs handle device discovery, connection, and disconnection, but at different levels.
Pythonic API lifecycle:
import duvc_ctl as duvc
# 1. Discovery (implicit)
# First camera found automatically during construction
with duvc.CameraController() as cam:
# 2. Connected
cam.brightness = 75
# 3. Auto-disconnect on exit
Result-based API lifecycle:
import duvc_ctl as duvc
# 1. Explicit discovery
devices = duvc.list_devices()
# 2. Explicit connection
camera_result = duvc.open_camera(devices[0])
if camera_result.is_ok():
camera = camera_result.value()
# 3. Use camera
brightness_result = camera.get(duvc.VidProp.Brightness)
# 4. Implicit disconnect when camera object is destroyed
Error handling strategies¶
Pythonic API (exception-based):
import duvc_ctl as duvc
try:
with duvc.CameraController() as cam:
cam.brightness = 999 # Out of range
except duvc.InvalidValueError as e:
print(f"Invalid value: {e}")
except duvc.PropertyNotSupportedError:
print("Brightness not supported")
except duvc.DeviceNotFoundError:
print("No camera found")
Advantages:
Errors interrupt execution; you can’t accidentally ignore them.
Idiomatic Python; most libraries use exceptions.
Compact code for the happy path.
Disadvantages:
Exception handling adds overhead (though typically small).
Stack unwinding can complicate debugging in some cases.
Result-based API (explicit):
import duvc_ctl as duvc
devices = duvc.list_devices()
if not devices:
print("No camera found")
else:
device = devices[0]
camera_result = duvc.open_camera(device)
if camera_result.is_ok():
camera = camera_result.value()
brightness_result = camera.get(duvc.VidProp.Brightness)
if brightness_result.is_ok():
setting = brightness_result.value()
print(f"Brightness: {setting.value}")
else:
error = brightness_result.error()
print(f"Error: {error.description()}")
else:
error = camera_result.error()
print(f"Failed to open camera: {error.description()}")
Advantages:
No hidden control flow; every error is explicit.
No exception overhead.
Detailed error codes and descriptions available.
Disadvantages:
More boilerplate (more
ifstatements).Error handling is mandatory; harder to “forget.”
Performance comparison¶
Both APIs ultimately call the same C++ code, so performance differences are small. However, there are measurable differences in specific scenarios:
Operation |
Pythonic |
Result-Based |
Notes |
|---|---|---|---|
Single property read (success) |
~100 µs |
~95 µs |
Pythonic has minimal overhead; difference negligible |
Single property read (error path) |
~200 µs |
~100 µs |
Exceptions slow down error handling |
Rapid property reads (1000x) |
~100 ms |
~95 ms |
Exception overhead adds up over many calls |
Device enumeration |
~10 ms |
~10 ms |
Same code path; no difference |
Context manager overhead |
~5 µs |
N/A |
Negligible |
(These values are estimates and not entirely accurate, timings may differ depending on the system and devices) Practical takeaway: For most use cases, the performance difference is imperceptible. Only if you’re performing thousands of property operations in a tight loop should you consider the Result-based API for performance.
Trade-offs summary¶
Choose Pythonic if:
You’re learning or prototyping.
Your code is straightforward with few error cases.
You like Python idioms and exceptions.
You want less boilerplate.
Performance is not critical.
Choose Result-based if:
You need detailed error recovery logic.
You’re building a production system that must handle failures gracefully.
You want explicit, predictable control flow (no exception throwing).
You’re processing high volumes of property operations.
You need to distinguish between different error types programmatically.
You prefer functional APIs and explicit error handling.
Mixing both APIs¶
You can use both APIs in the same application. The Pythonic API’s CameraController has a .core property that gives you the underlying Result-based Camera object:
import duvc_ctl as duvc
with duvc.CameraController() as cam:
# Use Pythonic API
cam.brightness = 75
# Drop down to Result-based API for detailed control
core_camera = cam.core
brightness_result = core_camera.get(duvc.VidProp.Brightness)
if brightness_result.is_ok():
setting = brightness_result.value()
print(f"Current brightness: {setting.value}")
This gives you flexibility: start simple with the Pythonic API, and switch to explicit Result handling when you need more control.
2.3 Core Design Patterns¶
This section explains the key design principles that shape duvc-ctl’s architecture, threading model, and internal behavior.
Resource ownership and RAII¶
duvc-ctl follows the Resource Acquisition Is Initialization (RAII) principle throughout its C++ core. Every resource (COM objects, device handles, property state) is tied to an object’s lifetime.
In C++:
class Device {
public:
Device() { /* acquire DirectShow resources */ }
~Device() { /* release DirectShow resources */ }
private:
IMFDeviceEnumerator* enumerator; // Owned; freed in destructor
};
When a Device object is destroyed, all associated DirectShow resources are automatically released. This prevents memory leaks and dangling pointers.
In Python (Pythonic API):
The CameraController manages a device lifecycle implicitly:
with duvc.CameraController() as cam:
# Resources acquired at __enter__
cam.brightness = 75
# Resources released at __exit__, even if an exception occurred
The with statement ensures cleanup happens, following Python’s context manager protocol.
In Python (Result-based API):
Resources are explicitly managed:
camera_result = duvc.open_camera(device)
if camera_result.is_ok():
camera = camera_result.value()
# Use camera
# Destructor called when camera goes out of scope
Python’s garbage collector triggers the destructor, which releases C++ resources via pybind11.
Threading and concurrency model¶
duvc-ctl is designed for multi-threaded environments but with careful constraints.
Module-level thread safety:
The module’s global state (device enumeration cache, module initialization) is protected by an internal mutex. Multiple threads can safely call:
import duvc_ctl as duvc
import threading
def thread_worker(index):
devices = duvc.list_devices() # Safe; uses module lock internally
print(f"Thread {index} found {len(devices)} devices")
threads = [threading.Thread(target=thread_worker, args=(i,)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
Per-camera thread safety:
Once a device is opened, accessing a single camera from multiple threads is not thread-safe without external synchronization. Each camera is a single-threaded object and must be accessed from the thread it was marshalled from. This is a result of the COM model used by windows.
# BAD: Race condition
import duvc_ctl as duvc
import threading
with duvc.CameraController() as cam:
def thread_worker():
cam.brightness = 50 # Unsafe if another thread accesses cam simultaneously
threads = [threading.Thread(target=thread_worker) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
# GOOD: Multiple cameras, one per thread
import duvc_ctl as duvc
import threading
devices = duvc.list_devices()
def thread_worker(device_index):
with duvc.CameraController(device_index) as cam:
cam.brightness = 50 # Each thread has its own camera; safe
threads = [threading.Thread(target=thread_worker, args=(i,)) for i in range(len(devices))]
for t in threads: t.start()
for t in threads: t.join()
Concurrency strategy:
Share devices across threads: Use a lock around camera operations.
Don’t share devices: Each thread gets its own camera (preferred).
Module-level operations (
list_devices(),open_camera()) are always safe.
C++ core vs. Python wrapper layer¶
duvc-ctl’s architecture has two distinct layers:
C++ Core:
Pure C++17 with minimal dependencies.
Direct access to Windows COM interfaces (DirectShow).
Handles device enumeration, property access, and DirectShow API calls.
No Python knowledge; can be used independently or from other languages via C bindings.
Python Wrapper (pybind11):
Binds C++ types to Python (Device, Camera, Result
, etc.). Translates Python exceptions to C++ exceptions and vice versa.
Manages object lifetime (Python GC ↔ C++ destructors).
Provides type conversion (Python
str↔ C++std::string).
The Pythonic API (CameraController) is written entirely in Python and uses the pybind11 bindings internally:
User Python Code
↓
CameraController (Python)
↓
pybind11 Bindings (Device, Camera, Result<T>)
↓
C++ Core
↓
DirectShow & Windows COM
This layering allows:
The C++ core to evolve independently of Python bindings.
Python code to be simple and Pythonic (using Python idioms).
The pybind11 layer to be thin (mostly type translations, not business logic).
Move semantics in Python bindings¶
The C++ core uses move semantics to efficiently transfer ownership of resources without copying:
// C++
Result<Camera> open_camera(const Device& device) {
auto camera = std::make_unique<Camera>(device);
return Result<Camera>(std::move(camera)); // Move, don't copy
}
pybind11 preserves this semantic when binding to Python. When you receive a Camera from Python, it’s a move-constructed object with full ownership:
camera_result = duvc.open_camera(device)
if camera_result.is_ok():
camera = camera_result.value() # Efficient transfer of ownership
Internally, pybind11 uses holder types to manage the C++ object’s lifetime through Python’s garbage collector. When camera goes out of scope in Python, the C++ object is destroyed and its resources freed.
Connection management state machine¶
A camera connection follows these states:
State |
Description |
Valid Transitions |
|---|---|---|
Closed |
No active connection to device |
→ Opening |
Opening |
Attempting to connect |
→ Open, → Closed (on failure) |
Open |
Connected; properties can be read/written |
→ Closing |
Closing |
Disconnecting |
→ Closed |
Error |
Connection failed or lost |
→ Opening (retry) |
Pythonic API state transitions:
with duvc.CameraController() as cam: # Closed → Opening → Open
cam.brightness = 75 # Operations in Open state
# Open → Closing → Closed
Result-based API state transitions:
camera_result = duvc.open_camera(device) # Closed → Opening
if camera_result.is_ok(): # Succeeded; now Open
camera = camera_result.value()
brightness_result = camera.get(duvc.VidProp.Brightness)
if brightness_result.is_ok():
setting = brightness_result.value()
# Open → Closing → Closed (on camera destruction)
State violations (e.g., trying to access a property in Closed state) raise an exception or return an error.
Device discovery patterns¶
Device discovery follows a lazy enumeration pattern:
On demand: Devices are discovered when
list_devices()is called, not at module import.Caching: The device list is cached; repeated calls are fast.
Invalidation: The cache is invalidated if device hotplug events are detected (if callback registered).
Example:
import duvc_ctl as duvc
# First call: actual enumeration via DirectShow
devices1 = duvc.list_devices()
# Second call: returns cached list (no DirectShow overhead)
devices2 = duvc.list_devices()
# Register for device change notifications
def on_device_change(added, device_path):
print(f"Device {'added' if added else 'removed'}: {device_path}")
duvc.register_device_change_callback(on_device_change)
# When a device is plugged/unplugged, the callback fires and the cache is invalidated
Thread safety guarantees¶
duvc-ctl provides these thread-safety guarantees:
Operation |
Thread-Safe |
Notes |
|---|---|---|
|
✓ Yes |
Internally synchronized |
|
✓ Yes |
Returns independent Camera object |
|
✗ No |
Use lock if accessing from multiple threads |
|
✗ No |
Use lock if accessing from multiple threads |
Multiple cameras in different threads |
✓ Yes |
Each camera is isolated |
Device change callbacks |
✓ Yes |
Callbacks are thread-safe; executed on internal worker thread |
|
✓ Yes |
Safe to call from any thread |
Safe multi-threaded pattern:
import duvc_ctl as duvc
import threading
from threading import Lock
devices = duvc.list_devices()
# One camera per thread: no lock needed
def worker(device_index):
with duvc.CameraController(device_index) as cam:
cam.brightness = 75
threads = [threading.Thread(target=worker, args=(i,)) for i in range(len(devices))]
for t in threads: t.start()
for t in threads: t.join()
Unsafe pattern (and how to fix it):
# UNSAFE: Multiple threads accessing same camera
with duvc.CameraController() as cam:
def worker():
cam.brightness = 75 # Race condition!
threads = [threading.Thread(target=worker) for _ in range(4)]
# ...
# SAFE: Use a lock
with duvc.CameraController() as cam:
lock = Lock()
def worker():
with lock:
cam.brightness = 75 # Protected by lock
threads = [threading.Thread(target=worker) for _ in range(4)]
# ...