feat: implement logging setup and CSV telemetry logging for performance metrics
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,9 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
logs/
|
||||||
|
|
||||||
# Local/runtime data
|
# Local/runtime data
|
||||||
captures/photos/*
|
captures/photos/*
|
||||||
captures/videos/*
|
captures/videos/*
|
||||||
|
|||||||
@@ -2,9 +2,49 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass, field
|
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
|
@dataclass
|
||||||
@@ -14,8 +54,7 @@ class CameraInfo:
|
|||||||
device: QCameraDevice
|
device: QCameraDevice
|
||||||
name: str
|
name: str
|
||||||
id: str
|
id: str
|
||||||
formats: list[tuple[int, int, float]] = field(default_factory=list)
|
formats: list[CameraFormat] = field(default_factory=list)
|
||||||
# formats: list of (width, height, max_fps)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} [{self.id}]"
|
return f"{self.name} [{self.id}]"
|
||||||
@@ -31,30 +70,42 @@ class CameraEnumerator:
|
|||||||
cameras: list[CameraInfo] = []
|
cameras: list[CameraInfo] = []
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
formats: list[tuple[int, int, float]] = []
|
formats: list[CameraFormat] = []
|
||||||
for fmt in device.videoFormats():
|
for qfmt in device.videoFormats():
|
||||||
res = fmt.resolution()
|
res = qfmt.resolution()
|
||||||
fps = fmt.maxFrameRate()
|
formats.append(CameraFormat(
|
||||||
formats.append((res.width(), res.height(), fps))
|
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
|
# deduplicate; sort: largest area first, then fps descending
|
||||||
seen: set[tuple[int, int, float]] = set()
|
seen: set[tuple[int, int, float, str]] = set()
|
||||||
unique_formats: list[tuple[int, int, float]] = []
|
unique: list[CameraFormat] = []
|
||||||
for f in sorted(formats, key=lambda x: (x[0] * x[1], x[2]), reverse=True):
|
for f in sorted(
|
||||||
if f not in seen:
|
formats,
|
||||||
seen.add(f)
|
key=lambda x: (x.width * x.height, x.max_fps),
|
||||||
unique_formats.append(f)
|
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(
|
cam_id = CameraEnumerator._device_id(device)
|
||||||
CameraInfo(
|
cameras.append(CameraInfo(
|
||||||
device=device,
|
device=device,
|
||||||
name=device.description(),
|
name=device.description(),
|
||||||
id=device.id().toStdString()
|
id=cam_id,
|
||||||
if hasattr(device.id(), "toStdString")
|
formats=unique,
|
||||||
else device.id().data().decode("utf-8", errors="replace"),
|
))
|
||||||
formats=unique_formats,
|
|
||||||
)
|
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
|
return cameras
|
||||||
|
|
||||||
@@ -65,16 +116,17 @@ class CameraEnumerator:
|
|||||||
if device.isNull():
|
if device.isNull():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cameras = CameraEnumerator.list_cameras()
|
default_id = CameraEnumerator._device_id(device)
|
||||||
# find by id match
|
for cam in CameraEnumerator.list_cameras():
|
||||||
default_id = (
|
|
||||||
device.id().toStdString()
|
|
||||||
if hasattr(device.id(), "toStdString")
|
|
||||||
else device.id().data().decode("utf-8", errors="replace")
|
|
||||||
)
|
|
||||||
for cam in cameras:
|
|
||||||
if cam.id == default_id:
|
if cam.id == default_id:
|
||||||
return cam
|
return cam
|
||||||
|
|
||||||
# fallback: wrap directly
|
cameras = CameraEnumerator.list_cameras()
|
||||||
return cameras[0] if cameras else None
|
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")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from PySide6.QtMultimedia import (
|
|||||||
QVideoSink,
|
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
|
from app.config import DEFAULT_FPS, DEFAULT_HEIGHT, DEFAULT_WIDTH
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,14 +28,13 @@ class CameraService(QObject):
|
|||||||
camera_stopped() — camera stopped (clean shutdown)
|
camera_stopped() — camera stopped (clean shutdown)
|
||||||
camera_error(str) — camera error description
|
camera_error(str) — camera error description
|
||||||
format_changed(float) — actual FPS after format was applied
|
format_changed(float) — actual FPS after format was applied
|
||||||
(emitted after camera restarts with new format)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
frame_ready = Signal(QVideoFrame)
|
frame_ready = Signal(QVideoFrame)
|
||||||
camera_started = Signal()
|
camera_started = Signal()
|
||||||
camera_stopped = Signal()
|
camera_stopped = Signal()
|
||||||
camera_error = Signal(str)
|
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:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -44,8 +43,9 @@ class CameraService(QObject):
|
|||||||
self._session = QMediaCaptureSession(self)
|
self._session = QMediaCaptureSession(self)
|
||||||
self._sink = QVideoSink(self)
|
self._sink = QVideoSink(self)
|
||||||
self._current_info: CameraInfo | None = None
|
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_width: int = DEFAULT_WIDTH
|
||||||
self._desired_height: int = DEFAULT_HEIGHT
|
self._desired_height: int = DEFAULT_HEIGHT
|
||||||
self._desired_fps: float = float(DEFAULT_FPS)
|
self._desired_fps: float = float(DEFAULT_FPS)
|
||||||
@@ -85,28 +85,18 @@ class CameraService(QObject):
|
|||||||
logger.warning("Reconnect requested but no camera was previously started")
|
logger.warning("Reconnect requested but no camera was previously started")
|
||||||
|
|
||||||
def set_resolution(self, width: int, height: int) -> None:
|
def set_resolution(self, width: int, height: int) -> None:
|
||||||
"""
|
"""Request a new resolution — restarts camera to apply reliably."""
|
||||||
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).
|
|
||||||
"""
|
|
||||||
self._desired_width = width
|
self._desired_width = width
|
||||||
self._desired_height = height
|
self._desired_height = height
|
||||||
if self._current_info is not None:
|
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)
|
self.start(self._current_info)
|
||||||
|
|
||||||
def set_fps(self, fps: float) -> None:
|
def set_fps(self, fps: float) -> None:
|
||||||
"""
|
"""Request a new frame rate — restarts camera to apply reliably."""
|
||||||
Request a new frame rate.
|
|
||||||
|
|
||||||
Same stop+start strategy as set_resolution().
|
|
||||||
"""
|
|
||||||
self._desired_fps = fps
|
self._desired_fps = fps
|
||||||
if self._current_info is not None:
|
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)
|
self.start(self._current_info)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -117,10 +107,6 @@ class CameraService(QObject):
|
|||||||
def current_camera(self) -> CameraInfo | None:
|
def current_camera(self) -> CameraInfo | None:
|
||||||
return self._current_info
|
return self._current_info
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Internal video output accessors (kept for future use)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def video_sink(self) -> QVideoSink:
|
def video_sink(self) -> QVideoSink:
|
||||||
return self._sink
|
return self._sink
|
||||||
|
|
||||||
@@ -132,7 +118,6 @@ class CameraService(QObject):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _stop_camera(self) -> None:
|
def _stop_camera(self) -> None:
|
||||||
"""Stop and destroy the QCamera object without clearing _current_info."""
|
|
||||||
if self._camera is not None:
|
if self._camera is not None:
|
||||||
self._camera.stop()
|
self._camera.stop()
|
||||||
self._camera.errorOccurred.disconnect()
|
self._camera.errorOccurred.disconnect()
|
||||||
@@ -141,14 +126,7 @@ class CameraService(QObject):
|
|||||||
logger.debug("Camera stopped (internal)")
|
logger.debug("Camera stopped (internal)")
|
||||||
|
|
||||||
def _apply_format(self) -> None:
|
def _apply_format(self) -> None:
|
||||||
"""
|
"""Select and apply the best matching QCameraFormat before start()."""
|
||||||
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)
|
|
||||||
"""
|
|
||||||
if self._camera is None or self._current_info is None:
|
if self._camera is None or self._current_info is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -172,9 +150,10 @@ class CameraService(QObject):
|
|||||||
if best is not None:
|
if best is not None:
|
||||||
self._camera.setCameraFormat(best)
|
self._camera.setCameraFormat(best)
|
||||||
res = best.resolution()
|
res = best.resolution()
|
||||||
|
pf = pixel_format_name(best.pixelFormat())
|
||||||
logger.info(
|
logger.info(
|
||||||
"Camera format requested: %dx%d @ %.1f fps",
|
"Camera format requested: %s %dx%d @ %.1f fps",
|
||||||
res.width(), res.height(), best.maxFrameRate(),
|
pf, res.width(), res.height(), best.maxFrameRate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _log_actual_format(self) -> None:
|
def _log_actual_format(self) -> None:
|
||||||
@@ -184,18 +163,36 @@ class CameraService(QObject):
|
|||||||
fmt = self._camera.cameraFormat()
|
fmt = self._camera.cameraFormat()
|
||||||
res = fmt.resolution()
|
res = fmt.resolution()
|
||||||
actual_fps = fmt.maxFrameRate()
|
actual_fps = fmt.maxFrameRate()
|
||||||
|
pf = pixel_format_name(fmt.pixelFormat())
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Camera format ACTUAL: %dx%d @ %.1f fps",
|
"Camera format ACTUAL: %s %dx%d @ %.1f fps",
|
||||||
res.width(), res.height(), actual_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(
|
logger.warning(
|
||||||
"Requested %.1f fps but camera is delivering %.1f fps "
|
"Requested %.1f fps but camera delivering %.1f fps "
|
||||||
"(camera may not support this combination)",
|
"(camera may not support this resolution+fps combination)",
|
||||||
self._desired_fps, actual_fps,
|
self._desired_fps, actual_fps,
|
||||||
)
|
)
|
||||||
self.format_changed.emit(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:
|
def _on_frame(self, frame: QVideoFrame) -> None:
|
||||||
if frame.isValid():
|
if frame.isValid():
|
||||||
self.frame_ready.emit(frame)
|
self.frame_ready.emit(frame)
|
||||||
@@ -207,8 +204,11 @@ class CameraService(QObject):
|
|||||||
def _on_active_changed(self, active: bool) -> None:
|
def _on_active_changed(self, active: bool) -> None:
|
||||||
if active:
|
if active:
|
||||||
name = self._current_info.name if self._current_info else "?"
|
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)
|
logger.info("Camera active: %s", name)
|
||||||
self._log_actual_format() # report what the camera actually accepted
|
self._log_actual_format()
|
||||||
self.camera_started.emit()
|
self.camera_started.emit()
|
||||||
else:
|
else:
|
||||||
logger.info("Camera inactive")
|
logger.info("Camera inactive")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Application-wide constants and default settings."""
|
"""Application-wide constants and default settings."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
APP_NAME = "Duck Preview"
|
APP_NAME = "Duck Preview"
|
||||||
APP_VERSION = "0.1.0"
|
APP_VERSION = "0.1.0"
|
||||||
|
|
||||||
@@ -20,3 +22,8 @@ OVERLAY_MARGIN = 10
|
|||||||
|
|
||||||
# Frame dispatcher
|
# Frame dispatcher
|
||||||
DISPATCHER_MAX_QUEUE_SIZE = 2 # max pending frames per slow subscriber before drop
|
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
118
app/logging_setup.py
Normal 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
|
||||||
24
app/main.py
24
app/main.py
@@ -2,22 +2,27 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from app.config import APP_NAME
|
from app.config import APP_NAME, LOG_DIR
|
||||||
from app.ui.main_window import MainWindow
|
from app.logging_setup import setup_logging
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# Basic logging — WARNING by default; Debug menu toggles to DEBUG
|
session_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
logging.basicConfig(
|
log_path = setup_logging(LOG_DIR, session_id)
|
||||||
level=logging.WARNING,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
# 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 = QApplication(sys.argv)
|
||||||
app.setApplicationName(APP_NAME)
|
app.setApplicationName(APP_NAME)
|
||||||
@@ -25,9 +30,10 @@ def main() -> None:
|
|||||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
)
|
)
|
||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow(log_path=log_path)
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
|
logger.info("Application shutting down")
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
104
app/telemetry/csv_logger.py
Normal file
104
app/telemetry/csv_logger.py
Normal 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
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
from PySide6.QtWidgets import QLabel, QMainWindow, QSizePolicy, QStatusBar
|
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.config import APP_NAME, APP_VERSION
|
||||||
from app.overlay.telemetry_overlay import TelemetryOverlay
|
from app.overlay.telemetry_overlay import TelemetryOverlay
|
||||||
from app.pipeline.frame_dispatcher import FrameDispatcher
|
from app.pipeline.frame_dispatcher import FrameDispatcher
|
||||||
|
from app.telemetry.csv_logger import CsvTelemetryLogger
|
||||||
from app.telemetry.telemetry_collector import TelemetryCollector
|
from app.telemetry.telemetry_collector import TelemetryCollector
|
||||||
from app.ui.camera_view import CameraView
|
from app.ui.camera_view import CameraView
|
||||||
from app.ui.menu_bar import AppMenuBar
|
from app.ui.menu_bar import AppMenuBar
|
||||||
@@ -32,13 +34,13 @@ class MainWindow(QMainWindow):
|
|||||||
Signal flow:
|
Signal flow:
|
||||||
CameraService.frame_ready
|
CameraService.frame_ready
|
||||||
→ FrameDispatcher.dispatch
|
→ FrameDispatcher.dispatch
|
||||||
→ CameraView.on_frame (render frame)
|
→ CameraView.on_frame (render frame)
|
||||||
→ TelemetryCollector.on_frame (measure metrics)
|
→ TelemetryCollector.on_frame (measure metrics)
|
||||||
→ TelemetryOverlay.on_metrics_updated (feed overlay data)
|
→ TelemetryOverlay.on_metrics_updated (overlay data)
|
||||||
(CameraView repaints and calls TelemetryOverlay.paint())
|
→ CsvTelemetryLogger.on_metrics_updated (CSV file)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, log_path: Path | None = None) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
|
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
|
||||||
@@ -50,6 +52,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._dispatcher = FrameDispatcher(self)
|
self._dispatcher = FrameDispatcher(self)
|
||||||
self._telemetry = TelemetryCollector(parent=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) ---
|
# --- Camera view (central widget) ---
|
||||||
self._camera_view = CameraView(self)
|
self._camera_view = CameraView(self)
|
||||||
self._camera_view.setSizePolicy(
|
self._camera_view.setSizePolicy(
|
||||||
@@ -64,6 +73,8 @@ class MainWindow(QMainWindow):
|
|||||||
# --- Menu bar ---
|
# --- Menu bar ---
|
||||||
self._menu = AppMenuBar(self)
|
self._menu = AppMenuBar(self)
|
||||||
self.setMenuBar(self._menu)
|
self.setMenuBar(self._menu)
|
||||||
|
if log_path is not None:
|
||||||
|
self._menu.set_log_file_path(str(log_path.resolve()))
|
||||||
|
|
||||||
# --- Status bar ---
|
# --- Status bar ---
|
||||||
self._status_bar = QStatusBar(self)
|
self._status_bar = QStatusBar(self)
|
||||||
@@ -111,15 +122,19 @@ class MainWindow(QMainWindow):
|
|||||||
# CameraService → FrameDispatcher
|
# CameraService → FrameDispatcher
|
||||||
self._camera_service.frame_ready.connect(self._dispatcher.dispatch)
|
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)
|
self._dispatcher.subscribe(self._camera_view.on_frame, drop_if_busy=True)
|
||||||
|
|
||||||
# FrameDispatcher → TelemetryCollector — never drop, count every frame
|
# FrameDispatcher → TelemetryCollector — never drop, count every frame
|
||||||
self._dispatcher.subscribe(self._telemetry.on_frame, drop_if_busy=False)
|
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)
|
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
|
# CameraService → TelemetryCollector: keep target FPS in sync
|
||||||
self._camera_service.format_changed.connect(self._telemetry.set_target_fps)
|
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
|
def closeEvent(self, event) -> None: # noqa: N802
|
||||||
self._camera_service.stop()
|
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)
|
super().closeEvent(event)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from PySide6.QtGui import QAction, QActionGroup
|
|||||||
from PySide6.QtWidgets import QMenuBar, QWidget
|
from PySide6.QtWidgets import QMenuBar, QWidget
|
||||||
|
|
||||||
from app.camera.camera_enumerator import CameraInfo
|
from app.camera.camera_enumerator import CameraInfo
|
||||||
|
from app.logging_setup import set_console_level
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ class AppMenuBar(QMenuBar):
|
|||||||
self._build_menus()
|
self._build_menus()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API — called after camera enumeration
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def populate_cameras(self, cameras: list[CameraInfo]) -> None:
|
def populate_cameras(self, cameras: list[CameraInfo]) -> None:
|
||||||
@@ -52,7 +53,6 @@ class AppMenuBar(QMenuBar):
|
|||||||
self._cameras = cameras
|
self._cameras = cameras
|
||||||
menu = self._camera_menu
|
menu = self._camera_menu
|
||||||
|
|
||||||
# Remove existing camera actions (keep Reconnect + separator)
|
|
||||||
for action in list(menu.actions()):
|
for action in list(menu.actions()):
|
||||||
if action not in (self._reconnect_action, self._cam_separator):
|
if action not in (self._reconnect_action, self._cam_separator):
|
||||||
menu.removeAction(action)
|
menu.removeAction(action)
|
||||||
@@ -69,8 +69,7 @@ class AppMenuBar(QMenuBar):
|
|||||||
action.triggered.connect(self._on_camera_action)
|
action.triggered.connect(self._on_camera_action)
|
||||||
|
|
||||||
if cameras:
|
if cameras:
|
||||||
first = self._camera_group.actions()[0]
|
self._camera_group.actions()[0].setChecked(True)
|
||||||
first.setChecked(True)
|
|
||||||
|
|
||||||
def populate_formats(self, camera_info: CameraInfo) -> None:
|
def populate_formats(self, camera_info: CameraInfo) -> None:
|
||||||
"""Populate Resolution and FPS menus based on a camera's supported formats."""
|
"""Populate Resolution and FPS menus based on a camera's supported formats."""
|
||||||
@@ -78,7 +77,6 @@ class AppMenuBar(QMenuBar):
|
|||||||
self._populate_fps(camera_info)
|
self._populate_fps(camera_info)
|
||||||
|
|
||||||
def set_active_camera(self, camera_info: CameraInfo) -> None:
|
def set_active_camera(self, camera_info: CameraInfo) -> None:
|
||||||
"""Check the menu item matching camera_info."""
|
|
||||||
if self._camera_group is None:
|
if self._camera_group is None:
|
||||||
return
|
return
|
||||||
for action in self._camera_group.actions():
|
for action in self._camera_group.actions():
|
||||||
@@ -86,24 +84,31 @@ class AppMenuBar(QMenuBar):
|
|||||||
action.setChecked(True)
|
action.setChecked(True)
|
||||||
return
|
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
|
# Menu construction
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _build_menus(self) -> None:
|
def _build_menus(self) -> None:
|
||||||
# --- Camera menu ---
|
# Camera menu
|
||||||
self._camera_menu = self.addMenu("Camera")
|
self._camera_menu = self.addMenu("Camera")
|
||||||
self._cam_separator = self._camera_menu.addSeparator()
|
self._cam_separator = self._camera_menu.addSeparator()
|
||||||
self._reconnect_action = QAction("Reconnect", self)
|
self._reconnect_action = QAction("Reconnect", self)
|
||||||
self._reconnect_action.triggered.connect(self.reconnect_requested)
|
self._reconnect_action.triggered.connect(self.reconnect_requested)
|
||||||
self._camera_menu.addAction(self._reconnect_action)
|
self._camera_menu.addAction(self._reconnect_action)
|
||||||
|
|
||||||
# --- Video menu ---
|
# Video menu
|
||||||
self._video_menu = self.addMenu("Video")
|
self._video_menu = self.addMenu("Video")
|
||||||
self._res_menu = self._video_menu.addMenu("Resolution")
|
self._res_menu = self._video_menu.addMenu("Resolution")
|
||||||
self._fps_menu = self._video_menu.addMenu("FPS")
|
self._fps_menu = self._video_menu.addMenu("FPS")
|
||||||
|
|
||||||
# --- Debug menu ---
|
# Debug menu
|
||||||
debug_menu = self.addMenu("Debug")
|
debug_menu = self.addMenu("Debug")
|
||||||
|
|
||||||
self._overlay_action = QAction("Show Overlay", self)
|
self._overlay_action = QAction("Show Overlay", self)
|
||||||
@@ -118,20 +123,26 @@ class AppMenuBar(QMenuBar):
|
|||||||
self._log_action.toggled.connect(self._on_log_toggled)
|
self._log_action.toggled.connect(self._on_log_toggled)
|
||||||
debug_menu.addAction(self._log_action)
|
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:
|
def _populate_resolutions(self, camera_info: CameraInfo) -> None:
|
||||||
self._res_menu.clear()
|
self._res_menu.clear()
|
||||||
self._resolution_group = QActionGroup(self)
|
self._resolution_group = QActionGroup(self)
|
||||||
self._resolution_group.setExclusive(True)
|
self._resolution_group.setExclusive(True)
|
||||||
|
|
||||||
seen: set[tuple[int, int]] = set()
|
seen: set[tuple[int, int]] = set()
|
||||||
for w, h, _ in camera_info.formats:
|
for fmt in camera_info.formats:
|
||||||
key = (w, h)
|
key = (fmt.width, fmt.height)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
action = QAction(f"{w} × {h}", self)
|
action = QAction(f"{fmt.width} × {fmt.height}", self)
|
||||||
action.setCheckable(True)
|
action.setCheckable(True)
|
||||||
action.setData((w, h))
|
action.setData((fmt.width, fmt.height))
|
||||||
self._resolution_group.addAction(action)
|
self._resolution_group.addAction(action)
|
||||||
self._res_menu.addAction(action)
|
self._res_menu.addAction(action)
|
||||||
action.triggered.connect(self._on_resolution_action)
|
action.triggered.connect(self._on_resolution_action)
|
||||||
@@ -146,14 +157,14 @@ class AppMenuBar(QMenuBar):
|
|||||||
self._fps_group.setExclusive(True)
|
self._fps_group.setExclusive(True)
|
||||||
|
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
for _, _, fps in camera_info.formats:
|
for fmt in camera_info.formats:
|
||||||
key = round(fps)
|
key = round(fmt.max_fps)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
action = QAction(f"{key} fps", self)
|
action = QAction(f"{key} fps", self)
|
||||||
action.setCheckable(True)
|
action.setCheckable(True)
|
||||||
action.setData(float(fps))
|
action.setData(float(fmt.max_fps))
|
||||||
self._fps_group.addAction(action)
|
self._fps_group.addAction(action)
|
||||||
self._fps_menu.addAction(action)
|
self._fps_menu.addAction(action)
|
||||||
action.triggered.connect(self._on_fps_action)
|
action.triggered.connect(self._on_fps_action)
|
||||||
@@ -193,6 +204,5 @@ class AppMenuBar(QMenuBar):
|
|||||||
self.fps_selected.emit(fps)
|
self.fps_selected.emit(fps)
|
||||||
|
|
||||||
def _on_log_toggled(self, enabled: bool) -> None:
|
def _on_log_toggled(self, enabled: bool) -> None:
|
||||||
level = logging.DEBUG if enabled else logging.WARNING
|
set_console_level(enabled)
|
||||||
logging.getLogger().setLevel(level)
|
|
||||||
self.log_toggled.emit(enabled)
|
self.log_toggled.emit(enabled)
|
||||||
|
|||||||
Reference in New Issue
Block a user