Last Updated: 14 November 2025
Platform: Jetson Orin Nano with 2x IMX219 CSI cameras
Status: Fully functional dual-camera streaming system with web interface
A Flask-based camera streaming platform for NVIDIA Jetson with dual CSI cameras. Features real-time MJPEG streaming, hardware-accelerated processing via GStreamer, and a responsive web UI for camera control.
- Backend: Python/Flask with GStreamer (nvarguscamerasrc)
- Frontend: Vanilla HTML/CSS/JavaScript
- Streaming: Dual-stream architecture (full-res + preview JPEG)
- Hardware: 2x IMX219 cameras (1920x1080@60fps capable)
- Dual-stream GStreamer pipeline:
- Full-res branch: 1920x1080 BGR for processing
- Preview branch: Configurable JPEG stream (default 640x480)
- Dynamic controls: Brightness (EV compensation), gain (ISP digital), rotation, white balance, saturation, edge enhancement, noise reduction
- Settings persistence: Per-camera JSON files in
backend/camera/settings/jetson/ - Auto-load/save: Settings automatically restored on startup and saved on change
- Resolution switching: Stream resolution can be changed dynamically (auto-restarts camera)
- Dual-camera slots: Side-by-side video display
- Per-camera controls: Controls embedded under each camera feed
- Real-time updates: Slider changes apply immediately
- Reset buttons: Each control has a reset-to-default button
- Profile system: Save/load control presets
- Resolution display: Shows capture and stream resolutions
- Page refresh handling: Reconnects to already-streaming cameras automatically
- Brightness: -100 to +100 (maps to -2 to +2 EV compensation via
exposurecompensation) - Digital Gain: 0-100% (maps to ISP digital gain 1.0-8.0x via
ispdigitalgainrange) - Rotation: none, 90°CW, 90°CCW, 180°, flip-h, flip-v (via nvvidconv
flip-method) - Stream Resolution: 320x240, 640x480, 800x600, 1280x720, 1920x1080
- White Balance: off, auto, incandescent, fluorescent, warm-fluorescent, daylight, cloudy, twilight, shade
- Auto-exposure lock: Lock/unlock AE via
aelockproperty
nvarguscamerasrc → tee → [full-res: nvvidconv→videoconvert→BGR→appsink]
→ [preview: nvvidconv→resize→jpegenc→appsink]
-
nvarguscamerasrc limitations:
exposuretimerangeandgainrangeonly constrain auto-exposure, don't set fixed values- Used
exposurecompensation(-2 to +2 EV) for dynamic brightness control instead - Used
ispdigitalgainrangefor digital gain (works dynamically, unlike analoggainrange)
-
Control scoping:
- Each camera has independent controls (fixed bug where controls affected both cameras)
- Controls use
#controls-${cameraId}container scoping - Value displays use
#value-${cameraId}-${controlName}for uniqueness
-
Settings files:
- Location:
backend/camera/settings/jetson/camera_0.json,camera_1.json - Format: JSON with all control values
- Auto-saved on every control change
- Applied via
_apply_control()withsave=Falseto avoid recursion
- Location:
-
Page refresh behavior:
- Backend tracks streaming state in
enumerate_cameras()response - Frontend calls
restoreStreamingCameras()on load - Recreates video elements and loads controls without restarting cameras
- Backend tracks streaming state in
backend/
├── app.py # Flask API server
├── camera/
│ ├── jetson.py # Jetson camera backend (613 lines)
│ ├── base.py # Abstract base class
│ ├── factory.py # Platform detection
│ ├── groups.py # Camera grouping
│ ├── profiles/jetson/ # Control presets (indoor, outdoor, low_light)
│ └── settings/jetson/ # Per-camera persistent settings
├── features/ # Plugin framework for image processing
└── workflows/ # Multi-camera workflow system
frontend/
├── index.html # Main UI (146 lines)
├── app.js # Client logic (768 lines)
└── style.css # Styling (509 lines)
- Add to
get_controls()injetson.py:
'control_name': {
'type': 'range|bool|menu',
'min': 0, 'max': 100, # for range
'options': [...], # for menu
'default': value,
'current': self.get_control(camera_id, 'control_name') or default,
'label': 'Display Name',
'description': 'Help text',
'platform_name': 'gstreamer-property' # if applicable
}-
Add to
_get_default_controls() -
Add handler in
_apply_control():
elif control_name == 'control_name':
# Apply to GStreamer element
src.set_property('property-name', value)- Add to frontend category in
app.js:
const categories = {
'Category': ['control_name'],
...
}Check GStreamer pipeline:
gst-inspect-1.0 nvarguscamerasrc
gst-launch-1.0 nvarguscamerasrc sensor-id=0 ! fakesinkView backend logs:
- Terminal running
python -m backend.appshows all debug output - Look for "Setting rotation for camera X" messages to verify control routing
Check saved settings:
cat backend/camera/settings/jetson/camera_0.json-
Exposure control doesn't change actual exposure time:
- nvarguscamerasrc doesn't support true manual exposure on running pipeline
- Workaround: Using
exposurecompensationfor relative brightness adjustment - Works with auto-exposure enabled, provides -2 to +2 EV range
-
Analog gain control doesn't work:
gainrangeproperty constrains auto-gain but doesn't set fixed value- Workaround: Using
ispdigitalgainrangefor digital gain amplification - Maps 0-100% to ISP digital gain 1.0-8.0x
-
Stream resolution requires restart:
- GStreamer caps can't be changed on running pipeline
- Implementation auto-stops and restarts camera with new config
- All other settings are reapplied after restart
python3-gi python3-gst-1.0 gstreamer1.0-tools
gstreamer1.0-plugins-base gstreamer1.0-plugins-goodpython3 -m venv venv --system-site-packages # Need system-site-packages for GStreamer
source venv/bin/activate
pip install flask numpycd /home/dev/utils/web-preview
source venv/bin/activate
python -m backend.app
# Access at http://localhost:5000 or http://<jetson-ip>:5000GET /api/cameras- List cameras with streaming statusPOST /api/cameras/<id>/start- Start camera with configPOST /api/cameras/<id>/stop- Stop cameraGET /api/cameras/<id>/stream- MJPEG stream endpoint
GET /api/cameras/<id>/controls- Get all controls + resolution infoPUT /api/cameras/<id>/control/<name>- Set control value- Body:
{"value": <value>}
- Body:
GET /api/cameras/<id>/profiles- List available profilesGET /api/cameras/<id>/profiles/<name>- Get profile settingsPOST /api/cameras/<id>/profiles/<name>- Save profilePOST /api/cameras/<id>/profile- Apply profile- Body:
{"profile_name": "name"}
- Body:
Problem: Changing rotation on camera 0 rotated both cameras
Cause: attachControlListeners() used document.querySelectorAll() globally
Solution: Scoped to container: controlsContainer.querySelectorAll('[data-control]')
Problem: Refreshing page showed no cameras even though backend still streaming
Cause: Frontend didn't check streaming status on load
Solution:
- Backend returns
streaming: true/falsein camera list - Frontend calls
restoreStreamingCameras()to recreate UI
Problem: Control values reset on app restart
Cause: No persistence layer
Solution:
- Created
backend/camera/settings/jetson/directory - Auto-save on every control change
- Auto-load on camera start
- Capture resolution control (currently fixed at 1920x1080)
- Frame rate control
- Snapshot/recording functionality
- Multi-camera synchronization
- Feature plugins (motion detection, object tracking)
- Workflow automation
- WebRTC streaming (lower latency than MJPEG)
- True manual exposure control (may require v4l2src instead of nvarguscamerasrc)
- Understand the platform: Jetson Orin Nano, 2x CSI cameras, GStreamer-based
- Check running state:
ps aux | grep python- is server running? - View settings:
ls backend/camera/settings/jetson/- see saved camera configs - Test cameras: Access web UI at http://localhost:5000
- Check logs: Terminal output shows all control changes and debug info
- Key files:
backend/camera/jetson.py(camera logic),frontend/app.js(UI logic)
- "Add control for X" → See "Adding a New Camera Control" above
- "Control not working" → Check if it's in
_apply_control(), verify GStreamer property name - "Settings not saving" → Check
backend/camera/settings/jetson/camera_X.jsonexists - "Camera won't start" → Check GStreamer pipeline, verify camera hardware with
gst-launch-1.0