feat: implement logging setup and CSV telemetry logging for performance metrics

This commit is contained in:
2026-05-12 22:15:50 +02:00
parent aec286c5ec
commit d62416db4e
9 changed files with 426 additions and 106 deletions

3
.gitignore vendored
View File

@@ -12,6 +12,9 @@ __pycache__/
venv/
env/
# Log files
logs/
# Local/runtime data
captures/photos/*
captures/videos/*

View File

@@ -2,9 +2,49 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices
from PySide6.QtMultimedia import QCameraDevice, QMediaDevices, QVideoFrameFormat
logger = logging.getLogger(__name__)
# Map Qt pixel format enum → human-readable string
_PIXEL_FORMAT_NAMES: dict[QVideoFrameFormat.PixelFormat, str] = {
QVideoFrameFormat.PixelFormat.Format_BGRA8888: "BGRA",
QVideoFrameFormat.PixelFormat.Format_BGRX8888: "BGRX",
QVideoFrameFormat.PixelFormat.Format_ABGR8888: "ABGR",
QVideoFrameFormat.PixelFormat.Format_ARGB8888: "ARGB",
QVideoFrameFormat.PixelFormat.Format_RGBA8888: "RGBA",
QVideoFrameFormat.PixelFormat.Format_RGBX8888: "RGBX",
QVideoFrameFormat.PixelFormat.Format_YUV420P: "YUV420P",
QVideoFrameFormat.PixelFormat.Format_YUV422P: "YUV422P",
QVideoFrameFormat.PixelFormat.Format_NV12: "NV12",
QVideoFrameFormat.PixelFormat.Format_NV21: "NV21",
QVideoFrameFormat.PixelFormat.Format_UYVY: "UYVY",
QVideoFrameFormat.PixelFormat.Format_YUYV: "YUY2",
QVideoFrameFormat.PixelFormat.Format_Jpeg: "MJPG",
QVideoFrameFormat.PixelFormat.Format_Y8: "GRAY8",
QVideoFrameFormat.PixelFormat.Format_Y16: "GRAY16",
}
def pixel_format_name(fmt: QVideoFrameFormat.PixelFormat) -> str:
"""Return a short human-readable name for a Qt pixel format."""
return _PIXEL_FORMAT_NAMES.get(fmt, f"fmt_{fmt.value if hasattr(fmt, 'value') else fmt}")
@dataclass
class CameraFormat:
"""One supported video format entry for a camera device."""
width: int
height: int
max_fps: float
pixel_format: str # e.g. "MJPG", "YUY2", "NV12", "BGRA"
def __str__(self) -> str:
return f"{self.pixel_format:6s} {self.width}x{self.height} @ {self.max_fps:.1f} fps"
@dataclass
@@ -14,8 +54,7 @@ class CameraInfo:
device: QCameraDevice
name: str
id: str
formats: list[tuple[int, int, float]] = field(default_factory=list)
# formats: list of (width, height, max_fps)
formats: list[CameraFormat] = field(default_factory=list)
def __str__(self) -> str:
return f"{self.name} [{self.id}]"
@@ -31,30 +70,42 @@ class CameraEnumerator:
cameras: list[CameraInfo] = []
for device in devices:
formats: list[tuple[int, int, float]] = []
for fmt in device.videoFormats():
res = fmt.resolution()
fps = fmt.maxFrameRate()
formats.append((res.width(), res.height(), fps))
formats: list[CameraFormat] = []
for qfmt in device.videoFormats():
res = qfmt.resolution()
formats.append(CameraFormat(
width=res.width(),
height=res.height(),
max_fps=qfmt.maxFrameRate(),
pixel_format=pixel_format_name(qfmt.pixelFormat()),
))
# deduplicate and sort: largest resolution first, then fps descending
seen: set[tuple[int, int, float]] = set()
unique_formats: list[tuple[int, int, float]] = []
for f in sorted(formats, key=lambda x: (x[0] * x[1], x[2]), reverse=True):
if f not in seen:
seen.add(f)
unique_formats.append(f)
# deduplicate; sort: largest area first, then fps descending
seen: set[tuple[int, int, float, str]] = set()
unique: list[CameraFormat] = []
for f in sorted(
formats,
key=lambda x: (x.width * x.height, x.max_fps),
reverse=True,
):
key = (f.width, f.height, f.max_fps, f.pixel_format)
if key not in seen:
seen.add(key)
unique.append(f)
cameras.append(
CameraInfo(
device=device,
name=device.description(),
id=device.id().toStdString()
if hasattr(device.id(), "toStdString")
else device.id().data().decode("utf-8", errors="replace"),
formats=unique_formats,
)
)
cam_id = CameraEnumerator._device_id(device)
cameras.append(CameraInfo(
device=device,
name=device.description(),
id=cam_id,
formats=unique,
))
logger.info("Cameras found: %d", len(cameras))
for idx, cam in enumerate(cameras):
logger.info(" [%d] %s (id: %s)", idx, cam.name, cam.id)
for fmt in cam.formats:
logger.info(" %s", fmt)
return cameras
@@ -65,16 +116,17 @@ class CameraEnumerator:
if device.isNull():
return None
cameras = CameraEnumerator.list_cameras()
# find by id match
default_id = (
device.id().toStdString()
if hasattr(device.id(), "toStdString")
else device.id().data().decode("utf-8", errors="replace")
)
for cam in cameras:
default_id = CameraEnumerator._device_id(device)
for cam in CameraEnumerator.list_cameras():
if cam.id == default_id:
return cam
# fallback: wrap directly
cameras = CameraEnumerator.list_cameras()
return cameras[0] if cameras else None
@staticmethod
def _device_id(device: QCameraDevice) -> str:
raw = device.id()
if hasattr(raw, "toStdString"):
return raw.toStdString()
return raw.data().decode("utf-8", errors="replace")

View File

@@ -12,7 +12,7 @@ from PySide6.QtMultimedia import (
QVideoSink,
)
from app.camera.camera_enumerator import CameraInfo
from app.camera.camera_enumerator import CameraInfo, pixel_format_name
from app.config import DEFAULT_FPS, DEFAULT_HEIGHT, DEFAULT_WIDTH
logger = logging.getLogger(__name__)
@@ -28,14 +28,13 @@ class CameraService(QObject):
camera_stopped() — camera stopped (clean shutdown)
camera_error(str) — camera error description
format_changed(float) — actual FPS after format was applied
(emitted after camera restarts with new format)
"""
frame_ready = Signal(QVideoFrame)
camera_started = Signal()
camera_stopped = Signal()
camera_error = Signal(str)
format_changed = Signal(float) # actual FPS delivered by camera after format change
format_changed = Signal(float)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -44,8 +43,9 @@ class CameraService(QObject):
self._session = QMediaCaptureSession(self)
self._sink = QVideoSink(self)
self._current_info: CameraInfo | None = None
self._session_logged: bool = False
# Desired format — applied on next (re)start
# Desired format — applied on every (re)start
self._desired_width: int = DEFAULT_WIDTH
self._desired_height: int = DEFAULT_HEIGHT
self._desired_fps: float = float(DEFAULT_FPS)
@@ -85,28 +85,18 @@ class CameraService(QObject):
logger.warning("Reconnect requested but no camera was previously started")
def set_resolution(self, width: int, height: int) -> None:
"""
Request a new resolution.
The camera is stopped and restarted so the backend reliably applies
the new format (QCamera.setCameraFormat on an active camera is often
silently ignored by Media Foundation on Windows).
"""
"""Request a new resolution — restarts camera to apply reliably."""
self._desired_width = width
self._desired_height = height
if self._current_info is not None:
logger.info("Resolution change requested: %dx%d — restarting camera", width, height)
logger.info("Resolution change: %dx%d — restarting camera", width, height)
self.start(self._current_info)
def set_fps(self, fps: float) -> None:
"""
Request a new frame rate.
Same stop+start strategy as set_resolution().
"""
"""Request a new frame rate — restarts camera to apply reliably."""
self._desired_fps = fps
if self._current_info is not None:
logger.info("FPS change requested: %.1f — restarting camera", fps)
logger.info("FPS change: %.1f — restarting camera", fps)
self.start(self._current_info)
@property
@@ -117,10 +107,6 @@ class CameraService(QObject):
def current_camera(self) -> CameraInfo | None:
return self._current_info
# ------------------------------------------------------------------
# Internal video output accessors (kept for future use)
# ------------------------------------------------------------------
def video_sink(self) -> QVideoSink:
return self._sink
@@ -132,7 +118,6 @@ class CameraService(QObject):
# ------------------------------------------------------------------
def _stop_camera(self) -> None:
"""Stop and destroy the QCamera object without clearing _current_info."""
if self._camera is not None:
self._camera.stop()
self._camera.errorOccurred.disconnect()
@@ -141,14 +126,7 @@ class CameraService(QObject):
logger.debug("Camera stopped (internal)")
def _apply_format(self) -> None:
"""
Select the best matching QCameraFormat and apply it before start().
The format is chosen by score:
+1000 exact resolution match
+100 exact FPS match (within 1 fps)
-|Δpixels| area proximity (tie-breaker)
"""
"""Select and apply the best matching QCameraFormat before start()."""
if self._camera is None or self._current_info is None:
return
@@ -172,9 +150,10 @@ class CameraService(QObject):
if best is not None:
self._camera.setCameraFormat(best)
res = best.resolution()
pf = pixel_format_name(best.pixelFormat())
logger.info(
"Camera format requested: %dx%d @ %.1f fps",
res.width(), res.height(), best.maxFrameRate(),
"Camera format requested: %s %dx%d @ %.1f fps",
pf, res.width(), res.height(), best.maxFrameRate(),
)
def _log_actual_format(self) -> None:
@@ -184,18 +163,36 @@ class CameraService(QObject):
fmt = self._camera.cameraFormat()
res = fmt.resolution()
actual_fps = fmt.maxFrameRate()
pf = pixel_format_name(fmt.pixelFormat())
logger.info(
"Camera format ACTUAL: %dx%d @ %.1f fps",
res.width(), res.height(), actual_fps,
"Camera format ACTUAL: %s %dx%d @ %.1f fps",
pf, res.width(), res.height(), actual_fps,
)
if actual_fps != self._desired_fps:
if abs(actual_fps - self._desired_fps) > 0.5:
logger.warning(
"Requested %.1f fps but camera is delivering %.1f fps "
"(camera may not support this combination)",
"Requested %.1f fps but camera delivering %.1f fps "
"(camera may not support this resolution+fps combination)",
self._desired_fps, actual_fps,
)
self.format_changed.emit(actual_fps)
def _log_qt_backend(self) -> None:
"""Log the Qt multimedia backend in use (once per session)."""
try:
# QMediaDevices doesn't expose backend name directly in Qt6,
# but we can infer it from platform
import platform # noqa: PLC0415
system = platform.system()
backend = {
"Darwin": "AVFoundation",
"Windows": "Media Foundation (DirectShow fallback)",
"Linux": "GStreamer / V4L2",
}.get(system, system)
logger.info("Qt multimedia backend: %s", backend)
except Exception:
pass
def _on_frame(self, frame: QVideoFrame) -> None:
if frame.isValid():
self.frame_ready.emit(frame)
@@ -207,8 +204,11 @@ class CameraService(QObject):
def _on_active_changed(self, active: bool) -> None:
if active:
name = self._current_info.name if self._current_info else "?"
if not self._session_logged:
self._log_qt_backend()
self._session_logged = True
logger.info("Camera active: %s", name)
self._log_actual_format() # report what the camera actually accepted
self._log_actual_format()
self.camera_started.emit()
else:
logger.info("Camera inactive")

View File

@@ -1,5 +1,7 @@
"""Application-wide constants and default settings."""
from pathlib import Path
APP_NAME = "Duck Preview"
APP_VERSION = "0.1.0"
@@ -20,3 +22,8 @@ OVERLAY_MARGIN = 10
# Frame dispatcher
DISPATCHER_MAX_QUEUE_SIZE = 2 # max pending frames per slow subscriber before drop
# Logging
LOG_DIR = Path("logs") # relative to CWD (project root)
MAX_LOG_FILES = 20 # oldest sessions are deleted when exceeded
TELEMETRY_CSV_INTERVAL_S = 5.0 # how often a CSV row is written (seconds)

118
app/logging_setup.py Normal file
View File

@@ -0,0 +1,118 @@
"""Logging initialisation — file + console handlers with session isolation."""
from __future__ import annotations
import logging
import platform
import sys
from pathlib import Path
from app.config import APP_NAME, APP_VERSION, MAX_LOG_FILES
_LOG_FORMAT = "%(asctime)s.%(msecs)03d [%(levelname)-7s] %(name)s: %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_console_handler: logging.StreamHandler | None = None
def setup_logging(log_dir: Path, session_id: str) -> Path:
"""
Configure the root logger for a new session.
Creates:
<log_dir>/duck-preview_<session_id>.log — DEBUG level, all messages
console StreamHandler — WARNING by default
Prunes oldest log files when count exceeds MAX_LOG_FILES.
Args:
log_dir: Directory where log files are stored.
session_id: Timestamp string used as filename suffix (e.g. "2026-05-12_14-30-00").
Returns:
Path to the created log file.
"""
global _console_handler
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"duck-preview_{session_id}.log"
root = logging.getLogger()
root.setLevel(logging.DEBUG) # handlers filter individually
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
# --- File handler — always DEBUG ---
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
# --- Console handler — WARNING by default, toggled by Debug menu ---
_console_handler = logging.StreamHandler(sys.stdout)
_console_handler.setLevel(logging.WARNING)
_console_handler.setFormatter(formatter)
root.addHandler(_console_handler)
# Write session header to file
_write_session_header(log_path, session_id)
# Prune old log files
_prune_old_logs(log_dir, log_path)
return log_path
def set_console_level(debug: bool) -> None:
"""Toggle console handler between DEBUG and WARNING (called from Debug menu)."""
if _console_handler is not None:
_console_handler.setLevel(logging.DEBUG if debug else logging.WARNING)
def _write_session_header(log_path: Path, session_id: str) -> None:
"""Write a human-readable header block at the top of the log file."""
try:
import PySide6
pyside_version = PySide6.__version__
except Exception:
pyside_version = "unknown"
try:
import psutil
mem = psutil.virtual_memory()
cpu_count = psutil.cpu_count(logical=True)
mem_total_gb = mem.total / (1024 ** 3)
hw_info = f"{cpu_count} logical CPUs, {mem_total_gb:.1f} GB RAM"
except Exception:
hw_info = "unknown"
lines = [
"=" * 72,
f" {APP_NAME} {APP_VERSION}",
f" Session: {session_id}",
"=" * 72,
f" Platform : {platform.platform()}",
f" Python : {sys.version.split()[0]}",
f" PySide6 : {pyside_version}",
f" Hardware : {hw_info}",
f" Log file : {log_path.resolve()}",
"=" * 72,
"",
]
with log_path.open("w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
def _prune_old_logs(log_dir: Path, current: Path) -> None:
"""Delete oldest .log files if total count exceeds MAX_LOG_FILES."""
log_files = sorted(
[p for p in log_dir.glob("duck-preview_*.log") if p != current],
key=lambda p: p.stat().st_mtime,
)
excess = len(log_files) - (MAX_LOG_FILES - 1)
for path in log_files[:excess]:
try:
path.unlink()
logging.getLogger(__name__).debug("Pruned old log: %s", path.name)
except OSError:
pass

View File

@@ -2,22 +2,27 @@
from __future__ import annotations
import logging
import sys
from datetime import datetime
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
from app.config import APP_NAME
from app.ui.main_window import MainWindow
from app.config import APP_NAME, LOG_DIR
from app.logging_setup import setup_logging
def main() -> None:
# Basic logging — WARNING by default; Debug menu toggles to DEBUG
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
session_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_path = setup_logging(LOG_DIR, session_id)
# Import after logging is ready so module-level loggers work correctly
import logging # noqa: PLC0415
from app.ui.main_window import MainWindow # noqa: PLC0415
logger = logging.getLogger(__name__)
logger.info("Application starting (session: %s)", session_id)
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
@@ -25,9 +30,10 @@ def main() -> None:
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
window = MainWindow()
window = MainWindow(log_path=log_path)
window.show()
logger.info("Application shutting down")
sys.exit(app.exec())

104
app/telemetry/csv_logger.py Normal file
View File

@@ -0,0 +1,104 @@
"""CsvTelemetryLogger — writes telemetry snapshots to a CSV file with throttling.
Design decisions:
- Does NOT use the logging module — writes directly via csv.writer so the file
is readable independently of the text log level.
- Flushes after every row so the file is intact even on crash or force-quit.
- Throttle: only one row per TELEMETRY_CSV_INTERVAL_S seconds, even if
metrics_updated fires every 500 ms. This keeps the file manageable for
long sessions (8 h @ 5 s interval = 5 760 rows).
"""
from __future__ import annotations
import csv
import time
from datetime import datetime
from pathlib import Path
from app.config import TELEMETRY_CSV_INTERVAL_S
from app.telemetry.telemetry_collector import TelemetrySnapshot
_CSV_HEADER = [
"timestamp",
"fps_got",
"fps_req",
"frame_time_ms",
"dropped_frames",
"cpu_sys_pct",
"cpu_core_pct",
"mem_mb",
]
class CsvTelemetryLogger:
"""
Receives TelemetrySnapshot objects and writes throttled rows to a CSV file.
Usage:
logger = CsvTelemetryLogger(path)
telemetry_collector.metrics_updated.connect(logger.on_metrics_updated)
# call logger.close() on application exit
"""
def __init__(
self,
path: Path,
interval_s: float = TELEMETRY_CSV_INTERVAL_S,
) -> None:
self._interval_s = interval_s
self._last_write_time: float = 0.0
self._rows_written: int = 0
path.parent.mkdir(parents=True, exist_ok=True)
self._file = path.open("w", newline="", encoding="utf-8")
self._writer = csv.writer(self._file)
self._writer.writerow(_CSV_HEADER)
self._file.flush()
# ------------------------------------------------------------------
# Slot — connect to TelemetryCollector.metrics_updated
# ------------------------------------------------------------------
def on_metrics_updated(self, snapshot: TelemetrySnapshot) -> None:
"""Write a row if the throttle interval has elapsed."""
now = time.monotonic()
if now - self._last_write_time < self._interval_s:
return
self._last_write_time = now
self._write_row(snapshot)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def close(self) -> None:
"""Flush and close the CSV file. Call on application shutdown."""
try:
self._file.flush()
self._file.close()
except OSError:
pass
@property
def rows_written(self) -> int:
return self._rows_written
# ------------------------------------------------------------------
# Private
# ------------------------------------------------------------------
def _write_row(self, snap: TelemetrySnapshot) -> None:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] # HH:MM:SS.mmm
self._writer.writerow([
ts,
f"{snap.fps:.1f}",
f"{snap.target_fps:.1f}" if snap.target_fps is not None else "",
f"{snap.frame_time_ms:.2f}",
snap.dropped_frames,
f"{snap.cpu_percent_sys:.1f}",
f"{snap.cpu_percent_core:.1f}",
f"{snap.memory_mb:.1f}" if snap.memory_mb is not None else "",
])
self._file.flush()
self._rows_written += 1

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
@@ -12,6 +13,7 @@ from app.camera.camera_service import CameraService
from app.config import APP_NAME, APP_VERSION
from app.overlay.telemetry_overlay import TelemetryOverlay
from app.pipeline.frame_dispatcher import FrameDispatcher
from app.telemetry.csv_logger import CsvTelemetryLogger
from app.telemetry.telemetry_collector import TelemetryCollector
from app.ui.camera_view import CameraView
from app.ui.menu_bar import AppMenuBar
@@ -32,13 +34,13 @@ class MainWindow(QMainWindow):
Signal flow:
CameraService.frame_ready
→ FrameDispatcher.dispatch
→ CameraView.on_frame (render frame)
→ TelemetryCollector.on_frame (measure metrics)
→ TelemetryOverlay.on_metrics_updated (feed overlay data)
(CameraView repaints and calls TelemetryOverlay.paint())
→ CameraView.on_frame (render frame)
→ TelemetryCollector.on_frame (measure metrics)
→ TelemetryOverlay.on_metrics_updated (overlay data)
→ CsvTelemetryLogger.on_metrics_updated (CSV file)
"""
def __init__(self) -> None:
def __init__(self, log_path: Path | None = None) -> None:
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
@@ -50,6 +52,13 @@ class MainWindow(QMainWindow):
self._dispatcher = FrameDispatcher(self)
self._telemetry = TelemetryCollector(parent=self)
# --- CSV telemetry logger ---
self._csv_logger: CsvTelemetryLogger | None = None
if log_path is not None:
csv_path = log_path.with_suffix(".csv")
self._csv_logger = CsvTelemetryLogger(csv_path)
logger.info("Telemetry CSV: %s", csv_path.resolve())
# --- Camera view (central widget) ---
self._camera_view = CameraView(self)
self._camera_view.setSizePolicy(
@@ -64,6 +73,8 @@ class MainWindow(QMainWindow):
# --- Menu bar ---
self._menu = AppMenuBar(self)
self.setMenuBar(self._menu)
if log_path is not None:
self._menu.set_log_file_path(str(log_path.resolve()))
# --- Status bar ---
self._status_bar = QStatusBar(self)
@@ -111,15 +122,19 @@ class MainWindow(QMainWindow):
# CameraService → FrameDispatcher
self._camera_service.frame_ready.connect(self._dispatcher.dispatch)
# FrameDispatcher → CameraView (render) — drop if busy: stay fluid
# FrameDispatcher → CameraView (render) — drop if busy
self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True)
# FrameDispatcher → TelemetryCollector — never drop, count every frame
self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False)
# TelemetryCollector → TelemetryOverlay (data only, no repaint trigger here)
# TelemetryCollector → overlay
self._telemetry.metrics_updated.connect(self._telemetry_overlay.on_metrics_updated)
# TelemetryCollector → CSV logger (throttled internally)
if self._csv_logger is not None:
self._telemetry.metrics_updated.connect(self._csv_logger.on_metrics_updated)
# CameraService → TelemetryCollector: keep target FPS in sync
self._camera_service.format_changed.connect(self._telemetry.set_target_fps)
@@ -171,4 +186,9 @@ class MainWindow(QMainWindow):
def closeEvent(self, event) -> None: # noqa: N802
self._camera_service.stop()
if self._csv_logger is not None:
logger.info(
"CSV telemetry: %d rows written", self._csv_logger.rows_written
)
self._csv_logger.close()
super().closeEvent(event)

View File

@@ -9,6 +9,7 @@ from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QMenuBar, QWidget
from app.camera.camera_enumerator import CameraInfo
from app.logging_setup import set_console_level
logger = logging.getLogger(__name__)
@@ -44,7 +45,7 @@ class AppMenuBar(QMenuBar):
self._build_menus()
# ------------------------------------------------------------------
# Public API — called after camera enumeration
# Public API
# ------------------------------------------------------------------
def populate_cameras(self, cameras: list[CameraInfo]) -> None:
@@ -52,7 +53,6 @@ class AppMenuBar(QMenuBar):
self._cameras = cameras
menu = self._camera_menu
# Remove existing camera actions (keep Reconnect + separator)
for action in list(menu.actions()):
if action not in (self._reconnect_action, self._cam_separator):
menu.removeAction(action)
@@ -69,8 +69,7 @@ class AppMenuBar(QMenuBar):
action.triggered.connect(self._on_camera_action)
if cameras:
first = self._camera_group.actions()[0]
first.setChecked(True)
self._camera_group.actions()[0].setChecked(True)
def populate_formats(self, camera_info: CameraInfo) -> None:
"""Populate Resolution and FPS menus based on a camera's supported formats."""
@@ -78,7 +77,6 @@ class AppMenuBar(QMenuBar):
self._populate_fps(camera_info)
def set_active_camera(self, camera_info: CameraInfo) -> None:
"""Check the menu item matching camera_info."""
if self._camera_group is None:
return
for action in self._camera_group.actions():
@@ -86,24 +84,31 @@ class AppMenuBar(QMenuBar):
action.setChecked(True)
return
def set_log_file_path(self, path: str) -> None:
"""Display the log file path as a disabled menu item in Debug menu."""
# Truncate long paths for display
display = path if len(path) <= 60 else "" + path[-57:]
self._log_file_action.setText(f"Log: {display}")
self._log_file_action.setToolTip(path)
# ------------------------------------------------------------------
# Menu construction
# ------------------------------------------------------------------
def _build_menus(self) -> None:
# --- Camera menu ---
# Camera menu
self._camera_menu = self.addMenu("Camera")
self._cam_separator = self._camera_menu.addSeparator()
self._reconnect_action = QAction("Reconnect", self)
self._reconnect_action.triggered.connect(self.reconnect_requested)
self._camera_menu.addAction(self._reconnect_action)
# --- Video menu ---
# Video menu
self._video_menu = self.addMenu("Video")
self._res_menu = self._video_menu.addMenu("Resolution")
self._fps_menu = self._video_menu.addMenu("FPS")
# --- Debug menu ---
# Debug menu
debug_menu = self.addMenu("Debug")
self._overlay_action = QAction("Show Overlay", self)
@@ -118,20 +123,26 @@ class AppMenuBar(QMenuBar):
self._log_action.toggled.connect(self._on_log_toggled)
debug_menu.addAction(self._log_action)
debug_menu.addSeparator()
self._log_file_action = QAction("Log: (not started)", self)
self._log_file_action.setEnabled(False)
debug_menu.addAction(self._log_file_action)
def _populate_resolutions(self, camera_info: CameraInfo) -> None:
self._res_menu.clear()
self._resolution_group = QActionGroup(self)
self._resolution_group.setExclusive(True)
seen: set[tuple[int, int]] = set()
for w, h, _ in camera_info.formats:
key = (w, h)
for fmt in camera_info.formats:
key = (fmt.width, fmt.height)
if key in seen:
continue
seen.add(key)
action = QAction(f"{w} × {h}", self)
action = QAction(f"{fmt.width} × {fmt.height}", self)
action.setCheckable(True)
action.setData((w, h))
action.setData((fmt.width, fmt.height))
self._resolution_group.addAction(action)
self._res_menu.addAction(action)
action.triggered.connect(self._on_resolution_action)
@@ -146,14 +157,14 @@ class AppMenuBar(QMenuBar):
self._fps_group.setExclusive(True)
seen: set[int] = set()
for _, _, fps in camera_info.formats:
key = round(fps)
for fmt in camera_info.formats:
key = round(fmt.max_fps)
if key in seen:
continue
seen.add(key)
action = QAction(f"{key} fps", self)
action.setCheckable(True)
action.setData(float(fps))
action.setData(float(fmt.max_fps))
self._fps_group.addAction(action)
self._fps_menu.addAction(action)
action.triggered.connect(self._on_fps_action)
@@ -193,6 +204,5 @@ class AppMenuBar(QMenuBar):
self.fps_selected.emit(fps)
def _on_log_toggled(self, enabled: bool) -> None:
level = logging.DEBUG if enabled else logging.WARNING
logging.getLogger().setLevel(level)
set_console_level(enabled)
self.log_toggled.emit(enabled)