feat: implement logging setup and CSV telemetry logging for performance metrics
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
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
|
||||
|
||||
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
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
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user