Skip to content

dsvsergey/camera-control-desktop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

camera-control-desktop

A PySide6 desktop application for controlling pan-tilt-zoom (PTZ) cameras through a pluggable driver abstraction. The repository ships with three drivers — a software mock, VISCA-over-IP and ONVIF — and a built-in live RTSP preview.

Features

  • Sidebar with the registered cameras and a per-item status indicator
  • Eight-direction pan/tilt pad with continuous (press-and-hold) motion
  • Zoom in/out controls with a shared speed slider
  • Live status panel: connection state and normalised pan/tilt/zoom
  • Live preview: RTSP video when a stream URL is configured, schematic PTZ visualisation otherwise
  • Six-slot preset system (save current position / recall on demand)
  • Rolling event log fed by the standard library logger
  • Cross-platform user-state persistence (camera list and presets)
  • Three shipped drivers: mock, visca_ip, onvif — selected at startup via the CAMERA_DRIVER environment variable

Architecture

The application is split into four layers, each with a single responsibility. The UI knows nothing about driver protocols, and drivers know nothing about widgets — they meet through CameraService.

┌─────────────────────────────────────────────────────────┐
│                          UI                             │
│   MainWindow, ControlPanel, PreviewPanel, …             │
└────────────────────────────┬────────────────────────────┘
                             │ (Qt signals / method calls)
┌────────────────────────────▼────────────────────────────┐
│                       Service                           │
│   CameraService — owns the active camera, presets,      │
│   persistence, error fan-out                            │
└──────────────┬──────────────────────────┬───────────────┘
               │                          │
┌──────────────▼─────────┐   ┌────────────▼──────────────┐
│        Driver          │   │         Storage           │
│  CameraDriver (ABC)    │   │  CameraStore — atomic     │
│  ├─ MockCameraDriver   │   │  JSON in user data dir    │
│  ├─ ViscaIpDriver      │   └───────────────────────────┘
│  └─ OnvifDriver        │
└────────────────────────┘

Driver abstraction

CameraDriver is a QObject with the lifecycle methods every PTZ camera needs (connect, disconnect, move, zoom, stop, go_to) and three state-change signals (status_changed, position_changed, error_occurred). All blocking I/O in the real drivers (UDP socket reads for VISCA, SOAP calls for ONVIF) runs on a dedicated worker QThread, so the GUI thread is never blocked.

A driver factory turns a Camera into a driver instance:

def make_my_driver(camera: Camera) -> CameraDriver:
    return MyHardwareDriver(host=camera.host, port=camera.port)

service = CameraService(driver_factory=make_my_driver)

Shipped drivers

Driver Module Transport Extra deps
mock app.services.drivers.mock none (in-memory)
visca_ip app.services.drivers.visca_ip UDP, port 52381 — (stdlib only)
onvif app.services.drivers.onvif_driver ONVIF Profile S/T onvif-zeep

Service layer

CameraService is the only object the UI talks to. It:

  • holds the camera library and per-camera preset slots,
  • maintains exactly one active camera with a bound driver,
  • re-emits driver signals from a stable place so widgets don't need to reconnect themselves whenever the active camera changes,
  • persists state changes through CameraStore (atomic JSON writes to the platform-appropriate user data directory).

Live preview

PreviewPanel switches automatically between two modes:

  • Live RTSP video when the active camera has a non-empty rtsp_url and the driver is connected. Frames are decoded with OpenCV on a worker QThread, converted to QImage and painted in a QLabel scaled to fit while keeping aspect ratio.
  • Schematic preview otherwise — a synthetic viewport that reacts to the current PTZ position, useful with the mock driver or before the user has configured a stream URL.

OpenCV is a soft dependency: install via the [video] extra to enable the live preview; the schematic mode keeps working without it.

UI

The window composes small, focused widgets — CameraListWidget, ControlPanel, PreviewPanel, StatusPanel, PresetPanel, LogPanel — each of which receives the service in its constructor and reacts to its signals. Widgets do not call drivers directly.

Project layout

app/
├── core/             # config, logging, paths
├── models/           # Camera, Position, Preset, enums
├── services/
│   ├── drivers/      # CameraDriver ABC + Mock, VISCA-IP, ONVIF impls
│   ├── video/        # RTSP capture worker
│   ├── storage.py    # JSON-backed CameraStore
│   └── camera_service.py
├── ui/
│   ├── widgets/      # composable widgets
│   ├── main_window.py
│   └── styles.qss
├── main.py           # entry point: configures logging, picks driver, builds app
└── __main__.py       # python -m app
tests/                # pytest + pytest-qt
pyproject.toml

Getting started

Requires Python 3.11 or newer.

# Create a virtual environment
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

# Install the application
pip install -e .                   # mock driver only
pip install -e ".[realtime]"       # + ONVIF and RTSP preview
pip install -e ".[dev]"            # + test tooling

# Run the GUI (mock driver by default)
python -m app

# Run with a real camera
CAMERA_DRIVER=visca_ip python -m app
CAMERA_DRIVER=onvif    python -m app

The application stores its state in the platform user data directory:

OS Path
macOS ~/Library/Application Support/camera-control-desktop/cameras.json
Linux ~/.local/share/camera-control-desktop/cameras.json
Windows %LOCALAPPDATA%\camera-control-desktop\cameras.json

Logs are written next to the data directory under the platform's user log location (rotated, 1 MiB × 3 backups).

Configuring a real camera

Edit the JSON state file (or extend the UI with a "Camera settings" dialog) to fill in the fields each driver needs:

{
  "id": "...",
  "name": "Studio A",
  "host": "192.168.1.10",
  "port": 52381,
  "rtsp_url": "rtsp://192.168.1.10:554/stream",
  "username": "admin",
  "password": "admin"
}
  • VISCA-IP uses host + port (default 52381).
  • ONVIF uses host + port (default 80) + username/password.
  • RTSP preview uses rtsp_url independently of the control driver.

Driver-specific notes

VISCA-over-IP. The driver implements the standard 8-byte VISCA-over-IP framing on plain UDP. Position values returned by the camera are vendor-dependent; the driver normalises them into [-1, 1] using configurable pan_max / tilt_max / zoom_max constants (DEFAULT_PAN_MAX = 0x2400, etc.). Adjust these per camera if reported positions look compressed or clipped.

ONVIF. Built on top of onvif-zeep. The driver uses ContinuousMove / Stop for joystick motion and AbsoluteMove for preset recall, polling GetStatus at 2 Hz. The first profile reported by the camera is used; cameras that expose multiple profiles are easily supported by extending _OnvifWorker.open to pick by name.

Tests

pytest

The suite covers the mock driver's state transitions, the service's orchestration logic, and the JSON persistence round-trip. Tests use pytest-qt to drive the Qt event loop, so a headless CI is fine — no display is required (QT_QPA_PLATFORM=offscreen is honoured by Qt).

The VISCA-IP and ONVIF drivers are not covered by automated tests because they require a real camera (or a non-trivial mock server) at the other end of the wire.

Implementing your own driver

from app.models.camera import Camera, Position
from app.models.enums import CameraStatus, Direction, ZoomDirection
from app.services.drivers.base import CameraDriver


class MyHardwareDriver(CameraDriver):
    def connect(self) -> None:
        self._set_status(CameraStatus.CONNECTING)
        # open transport, run handshake...
        self._set_status(CameraStatus.IDLE)

    def disconnect(self) -> None: ...
    def move(self, direction: Direction, speed: float) -> None: ...
    def zoom(self, direction: ZoomDirection, speed: float) -> None: ...
    def stop(self) -> None: ...
    def go_to(self, position: Position) -> None: ...


def make_my_driver(camera: Camera) -> MyHardwareDriver:
    return MyHardwareDriver(host=camera.host, port=camera.port)

Wire it up at startup:

from app.services.camera_service import CameraService
service = CameraService(driver_factory=make_my_driver)

The UI requires no changes.

License

MIT — see LICENSE (add your own copyright line when forking).

About

A PySide6 desktop application for controlling pan-tilt-zoom (PTZ) cameras through a pluggable driver abstraction. The repository ships with three drivers — a software mock, VISCA-over-IP and ONVIF — and a built-in live RTSP preview.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages