Skip to content
Draft
19 changes: 19 additions & 0 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,21 @@ def writegear_overrides(self) -> WriteGearOptions | None:
return {}


class WebStreamSettings(BaseModel):
"""On-demand JPEG web view of the raw tiled preview.

On/off and network exposure (localhost vs all interfaces) are local QSettings
preferences, not part of the shareable config, so loading someone else's
config can neither start the stream nor expose it on the network.
"""

port: int = Field(default=8080, ge=1, le=65535)
fps: float = Field(default=5.0, gt=0.0, le=30.0)
max_width: int = Field(default=640, ge=16)
max_height: int = Field(default=480, ge=16)
jpeg_quality: int = Field(default=70, ge=1, le=100)


class ApplicationSettings(BaseModel):
# optional: add a semantic version for migrations
version: int = 1
Expand All @@ -558,6 +573,7 @@ class ApplicationSettings(BaseModel):
recording: RecordingSettings = Field(default_factory=RecordingSettings)
bbox: BoundingBoxSettings = Field(default_factory=BoundingBoxSettings)
visualization: VisualizationSettings = Field(default_factory=VisualizationSettings)
web_stream: WebStreamSettings = Field(default_factory=WebStreamSettings)

@classmethod
def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings:
Expand All @@ -574,6 +590,7 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings:
recording = RecordingSettings(**recording_data)
bbox = BoundingBoxSettings(**bbox_data)
visualization = VisualizationSettings(**visualization_data)
web_stream = WebStreamSettings(**data.get("web_stream", {}))

return cls(
camera=camera,
Expand All @@ -582,6 +599,7 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings:
recording=recording,
bbox=bbox,
visualization=visualization,
web_stream=web_stream,
)

def to_dict(self) -> dict[str, Any]:
Expand All @@ -596,6 +614,7 @@ def to_dict(self) -> dict[str, Any]:
"recording": self.recording.model_dump(),
"bbox": self.bbox.model_dump(),
"visualization": self.visualization.model_dump(),
"web_stream": self.web_stream.model_dump(),
}

@classmethod
Expand Down
22 changes: 22 additions & 0 deletions dlclivegui/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
from .misc.drag_spinbox import ScrubSpinBox
from .misc.eliding_label import ElidingPathLabel
from .theme import LOGO, LOGO_ALPHA, AppStyle, apply_theme
from .web_stream import WebStreamController

logger = logging.getLogger("DLCLiveGUI")

Expand Down Expand Up @@ -152,6 +153,7 @@ def __init__(self, config: ApplicationSettings | None = None):
self._active_inference_camera_id: str | None = None
self._running_cams_ids: set[str] = set()
self._current_frame: np.ndarray | None = None
self._shown_frame: np.ndarray | None = None # _current_frame + on-screen overlays
self._raw_frame: np.ndarray | None = None
self._last_pose: PoseResult | None = None
self._dlc_active: bool = False
Expand Down Expand Up @@ -442,6 +444,16 @@ def _build_menus(self) -> None:
self.action_show_controls.toggled.connect(self.controls_dock.setVisible)
self.controls_dock.visibilityChanged.connect(self.action_show_controls.setChecked)
view_menu.addAction(self.action_show_controls)
## Web stream (raw tiled frames, pulled on demand by the browser)
self._web_stream = WebStreamController(
self,
self._settings_store,
frame_source=lambda: self._current_frame,
overlay_frame_source=lambda: self._shown_frame,
settings=self._config.web_stream,
show_status=self.statusBar().showMessage,
)
self._web_stream.add_to_menu(view_menu)
## --------------------
view_menu.addSeparator()
## Style actions
Expand Down Expand Up @@ -942,6 +954,8 @@ def _apply_config(self, config: ApplicationSettings, *, restore_local_prefs: boo
if hasattr(self, "bbox_color_combo"):
color_ui.set_bbox_combo_from_bgr(self.bbox_color_combo, self._bbox_color)

self._web_stream.apply_settings(config.web_stream, restore_enabled=restore_local_prefs)

# Processor
## Use custom processor checkbox state
if hasattr(self, "use_custom_proc_checkbox"):
Expand Down Expand Up @@ -973,6 +987,7 @@ def _current_config(self, *, allow_empty_model_path=False) -> ApplicationSetting
recording=self._recording_settings_from_ui(),
bbox=self._bbox_settings_from_ui(),
visualization=self._visualization_settings_from_ui(),
web_stream=self._web_stream.settings,
)

def _parse_json(self, value: str) -> dict:
Expand Down Expand Up @@ -1784,6 +1799,7 @@ def _on_multi_camera_stopped(self) -> None:
self.stop_preview_button.setEnabled(False)

self._current_frame = None
self._shown_frame = None
self._multi_camera_frames.clear()
self._multi_camera_display_ids.clear()
self._running_cams_ids.clear()
Expand Down Expand Up @@ -2133,6 +2149,7 @@ def _start_preview(self) -> None:
self.preview_button.setEnabled(False)
self.stop_preview_button.setEnabled(True)
self._current_frame = None
self._shown_frame = None
self._raw_frame = None
self._last_pose = None
self._multi_camera_frames.clear()
Expand Down Expand Up @@ -2330,6 +2347,7 @@ def _update_display_from_pending(self) -> None:
# Create tiled frame on demand (moved from camera thread for performance)
tiled = create_tiled_frame(self._multi_camera_frames, labels=self._multi_camera_display_ids)
if tiled is not None:
# Read by the web stream's server threads: replace, never draw on in place (overlays copy first).
self._current_frame = tiled
self._update_video_display(tiled)

Expand Down Expand Up @@ -2610,6 +2628,8 @@ def _update_video_display(self, frame: np.ndarray) -> None:
scale=self._dlc_tile_scale,
)

# Read by the web stream's server threads: replace, never draw on in place.
self._shown_frame = display_frame
rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)

h, w, ch = rgb.shape
Expand Down Expand Up @@ -2700,7 +2720,9 @@ def _set_combo_from_color(self, bgr: tuple[int, int, int]) -> None:

# ------------------------------------------------------------------
# Qt overrides
# ------------------------------------------------------------------
def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour
self._web_stream.shutdown()
if self.multi_camera_controller.is_running():
self.multi_camera_controller.stop(wait=True)

Expand Down
226 changes: 226 additions & 0 deletions dlclivegui/gui/web_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""View-menu actions and lifecycle for the web stream.

On/off, network exposure and overlays are local QSettings preferences, written only
by the user's menu toggles. Port, fps, size and quality come from the config JSON.
"""

from __future__ import annotations

import logging
import threading
from collections.abc import Callable

import numpy as np
from PySide6.QtCore import QSignalBlocker, QUrl
from PySide6.QtGui import QAction, QDesktopServices, QGuiApplication
from PySide6.QtWidgets import QMenu, QMessageBox, QWidget

from ..config import WebStreamSettings
from ..services.web_streamer import (
LOOPBACK_HOST,
NETWORK_HOST,
SHUTDOWN_TIMEOUT,
WebStreamer,
local_url,
network_url,
)
from ..utils.settings_store import DLCLiveGUISettingsStore

logger = logging.getLogger(__name__)

_NETWORK_WARNING = (
"Anyone on your network who can reach this computer will be able to watch the camera "
"stream, without a password.\n\n"
"The stream is view-only: it cannot control the app or read files.\n\n"
"Allow other devices?"
)
_COPY_LINK_TEXT = "Copy link for other devices"


def _set_checked_silently(action: QAction, checked: bool) -> None:
with QSignalBlocker(action):
action.setChecked(checked)


class WebStreamController:
"""Owns the web stream's menu actions, preferences and server lifecycle.

Menu: three switches (stream on/off, other devices allowed, overlays), then the URLs as actions.
"""

def __init__(
self,
parent: QWidget,
settings_store: DLCLiveGUISettingsStore,
frame_source: Callable[[], np.ndarray | None],
overlay_frame_source: Callable[[], np.ndarray | None],
settings: WebStreamSettings,
show_status: Callable[[str, int], None],
):
self._parent = parent
self._store = settings_store
self._raw_source = frame_source
self._overlay_source = overlay_frame_source
# Plain bool, not action.isChecked(): read from the server threads, where Qt objects are off-limits.
self._overlays = self._store.get_web_stream_overlays()
self._settings = settings.model_copy()
self._show_status = show_status
self._streamer: WebStreamer | None = None
self._pending_shutdown: threading.Thread | None = None

self.action_enable = QAction("Stream to browser", parent, checkable=True)
self.action_enable.toggled.connect(self._on_enable_toggled)
self.action_network = QAction("Allow other devices (no password)", parent, checkable=True)
self.action_network.setChecked(self._store.get_web_stream_allow_network()) # before connect: no prompt
self.action_network.toggled.connect(self._on_network_toggled)
self.action_overlays = QAction("Include on-screen overlays (pose, box)", parent, checkable=True)
self.action_overlays.setChecked(self._overlays)
self.action_overlays.toggled.connect(self._on_overlays_toggled)
self.action_open = QAction(parent)
self.action_open.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(self._local_url())))
self.action_copy = QAction(_COPY_LINK_TEXT, parent)
self.action_copy.triggered.connect(self._copy_network_url)
self._refresh_menu()

# ------------------------------------------------------------------
# Public API (main window)
def add_to_menu(self, menu: QMenu) -> QMenu:
"""Add a "Web stream" submenu holding the stream's actions to ``menu``."""
submenu = menu.addMenu("Web stream")
submenu.addActions([self.action_enable, self.action_network, self.action_overlays])
submenu.addSeparator()
submenu.addActions([self.action_open, self.action_copy])
submenu.aboutToShow.connect(self._refresh_menu) # LAN address can change while running
return submenu

@property
def settings(self) -> WebStreamSettings:
return self._settings.model_copy()

@property
def streamer(self) -> WebStreamer | None:
return self._streamer

def apply_settings(self, settings: WebStreamSettings, *, restore_enabled: bool = False) -> None:
"""Adopt config parameters; restart a running stream only if they changed.

``restore_enabled`` starts the stream if the persisted preference says so (startup only).
"""
changed = settings != self._settings
self._settings = settings.model_copy()
if self._streamer is not None and changed:
self._restart()
elif restore_enabled and self._streamer is None and self._store.get_web_stream_enabled():
self._start()
self._refresh_menu()

def shutdown(self) -> None:
"""Non-blocking stop for app exit; server threads are daemons and the OS releases the port."""
self._stop()

# ------------------------------------------------------------------
# Menu toggles: the only writers of the persisted preferences
def _on_enable_toggled(self, enabled: bool) -> None:
if enabled:
self._store.set_web_stream_enabled(self._start())
else:
self._store.set_web_stream_enabled(False)
if self._streamer is not None:
self._stop()
self._show_status("Web stream stopped", 3000)
self._refresh_menu()

def _on_network_toggled(self, allow: bool) -> None:
if allow and not self._confirm_network():
_set_checked_silently(self.action_network, False)
return
self._store.set_web_stream_allow_network(allow)
if self._streamer is not None:
self._restart()
self._refresh_menu()

def _on_overlays_toggled(self, enabled: bool) -> None:
# No restart: the source is chosen per request.
self._overlays = enabled
self._store.set_web_stream_overlays(enabled)

def _frame(self) -> np.ndarray | None:
"""Frame source for the server threads."""
return self._overlay_source() if self._overlays else self._raw_source()

def _confirm_network(self) -> bool:
reply = QMessageBox.question(
self._parent,
"Allow other devices?",
_NETWORK_WARNING,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return reply == QMessageBox.StandardButton.Yes

# ------------------------------------------------------------------
# URLs
def _host(self) -> str:
return NETWORK_HOST if self.action_network.isChecked() else LOOPBACK_HOST

def _port(self) -> int:
return self._streamer.port if self._streamer is not None else self._settings.port

def _local_url(self) -> str:
return local_url(self._host(), self._port())

def _network_url(self) -> str | None:
return network_url(self._port()) if self._host() == NETWORK_HOST else None

def _refresh_menu(self) -> None:
"""Sync URL actions with state; they are only clickable while the stream runs."""
running = self._streamer is not None
self.action_open.setText(f"Open {self._local_url()}")
self.action_open.setEnabled(running)
url = self._network_url() if running else None
self.action_copy.setText(f"{_COPY_LINK_TEXT}: {url}" if url else _COPY_LINK_TEXT)
self.action_copy.setEnabled(url is not None)

def _copy_network_url(self) -> None:
url = self._network_url()
if url is None:
return
QGuiApplication.clipboard().setText(url)
self._show_status(f"Copied {url}", 3000)

# ------------------------------------------------------------------
# Lifecycle
def _start(self) -> bool:
"""Start the stream and sync the menu check state. Returns whether it is running."""
# A quick off/on toggle can race the previous server's background shutdown; wait for the port.
pending, self._pending_shutdown = self._pending_shutdown, None
if pending is not None:
pending.join(timeout=SHUTDOWN_TIMEOUT)

host = self._host()
streamer = WebStreamer.from_settings(self._frame, self._settings, host=host)
try:
streamer.start()
except OSError as exc:
logger.warning("Failed to start web stream on %s:%s: %s", host, self._settings.port, exc)
self._show_status(f"Web stream failed to start on port {self._settings.port}: {exc}", 5000)
_set_checked_silently(self.action_enable, False)
return False

self._streamer = streamer
_set_checked_silently(self.action_enable, True)
if host == NETWORK_HOST:
logger.warning("Web stream is reachable from the network without authentication")
self._show_status(f"Web stream at {self._local_url()} (links in View > Web stream)", 8000)
return True

def _stop(self, wait: bool = False) -> None:
streamer, self._streamer = self._streamer, None
if streamer is not None:
self._pending_shutdown = streamer.stop(wait=wait)
_set_checked_silently(self.action_enable, False)

def _restart(self) -> None:
# Blocking stop so the new server can rebind the port; user/config actions only, not a hot path.
self._stop(wait=True)
self._start()
Loading
Loading