Add main application structure and logging configuration
- Implement main application entry point in duck-ocr.py - Create logging configuration in logging_config.py - Add video streaming functionality in camera.py - Introduce main window UI in main_window.py - Include SVG assets for UI buttons and icons - Update .gitignore to exclude log files - Add placeholder .gitkeep files for empty directories
1
.gitignore
vendored
@@ -29,3 +29,4 @@ captures/videos/*
|
||||
runs/
|
||||
*.onnx
|
||||
*.engine
|
||||
*.log
|
||||
148
app/camera.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from PySide6.QtCore import QThread, QTimer, QMutex, QWaitCondition, Signal, QObject, QElapsedTimer
|
||||
import cv2
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class VideoMetrics:
|
||||
frames_processed: int
|
||||
frames_dropped: int
|
||||
last_frame_time: float
|
||||
last_cap_time: float
|
||||
fps_average: float
|
||||
fps_last_time: float
|
||||
fps_frame_count: float
|
||||
|
||||
def update_fps(self) -> None:
|
||||
fps_now = time.perf_counter()
|
||||
elypsed = fps_now - self.fps_last_time
|
||||
if elypsed < 1.0:
|
||||
return
|
||||
|
||||
self.fps_frame_count = self.frames_processed - self.fps_frame_count
|
||||
self.fps_average = self.fps_frame_count / elypsed
|
||||
self.fps_frame_count = self.frames_processed
|
||||
self.fps_last_time = fps_now
|
||||
|
||||
|
||||
class VideoStreamWorker(QObject):
|
||||
# Emitowany z wątku roboczego - będzie przenoszony do głównego wątku przez VideoStream
|
||||
_internal_frame = Signal(object)
|
||||
|
||||
def __init__(self, source):
|
||||
super().__init__()
|
||||
self.source = source
|
||||
self.cap = None
|
||||
self.fps = 30.0
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
self.running = False
|
||||
self.metrics = VideoMetrics(
|
||||
frames_processed=0,
|
||||
frames_dropped=0,
|
||||
last_frame_time=0.0,
|
||||
last_cap_time=0.0,
|
||||
fps_average=0.0,
|
||||
fps_last_time=time.perf_counter(),
|
||||
fps_frame_count=0.0
|
||||
)
|
||||
|
||||
logger.debug(f"VideoStreamWorker initialized with source: {source}")
|
||||
|
||||
def set_source(self, source):
|
||||
logger.debug(f"Setting new video source: {source}")
|
||||
self.source = source
|
||||
if self.running:
|
||||
self.stop()
|
||||
self.run()
|
||||
|
||||
def run(self):
|
||||
"""Główna pętla wątku roboczego."""
|
||||
if self.source is None:
|
||||
logger.warning("No video source provided")
|
||||
return
|
||||
|
||||
self.cap = cv2.VideoCapture(self.source)
|
||||
if not self.cap.isOpened():
|
||||
logger.error(f"Failed to open video source: {self.source}")
|
||||
return
|
||||
|
||||
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
||||
if self.fps <= 0:
|
||||
self.fps = 30.0
|
||||
|
||||
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
|
||||
logger.debug(f"Video opened: {self.source} (fps: {self.fps}, size: {self.width}x{self.height})")
|
||||
|
||||
frame_interval = 1.0 / self.fps
|
||||
self.running = True
|
||||
frame_emit_time = time.perf_counter()
|
||||
|
||||
while self.running:
|
||||
next_frame_time = frame_emit_time + frame_interval
|
||||
|
||||
ret, frame = self.cap.read()
|
||||
|
||||
if not ret:
|
||||
logger.debug("End of video stream or read error")
|
||||
break
|
||||
|
||||
current_time = time.perf_counter()
|
||||
self.metrics.last_cap_time = current_time - frame_emit_time
|
||||
|
||||
if current_time < next_frame_time:
|
||||
sleep_time = next_frame_time - current_time
|
||||
time.sleep(sleep_time)
|
||||
else:
|
||||
self.metrics.frames_dropped += 1
|
||||
logger.debug(f"Frame drops counted: {self.metrics.frames_dropped}")
|
||||
|
||||
frame_emit_time = time.perf_counter()
|
||||
self._internal_frame.emit(frame)
|
||||
|
||||
self.metrics.frames_processed += 1
|
||||
self.metrics.update_fps()
|
||||
|
||||
self.cap.release()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
self.mutex.lock()
|
||||
self.condition.wakeAll()
|
||||
self.mutex.unlock()
|
||||
|
||||
|
||||
class VideoStream(QObject):
|
||||
"""Klasa fasadowa do użycia w głównym wątku GUI."""
|
||||
frame_ready = Signal(object) # To będzie emitowane z głównego wątku
|
||||
|
||||
def __init__(self, source):
|
||||
super().__init__()
|
||||
self.worker = VideoStreamWorker(source)
|
||||
self.worker_thread = QThread()
|
||||
|
||||
# Przenosimy workera do osobnego wątku
|
||||
self.worker.moveToThread(self.worker_thread)
|
||||
|
||||
# Łączymy sygnały
|
||||
self.worker._internal_frame.connect(self._on_frame)
|
||||
self.worker_thread.started.connect(self.worker.run)
|
||||
self.worker_thread.finished.connect(self.worker.deleteLater)
|
||||
self.worker_thread.finished.connect(self.worker_thread.deleteLater)
|
||||
|
||||
def start(self):
|
||||
self.worker_thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.worker.stop()
|
||||
self.worker_thread.quit()
|
||||
self.worker_thread.wait()
|
||||
|
||||
def _on_frame(self, frame):
|
||||
"""Slot wywoływany w głównym wątku po otrzymaniu klatki."""
|
||||
# Tutaj możesz dodać korekcję ekspozycji jeśli nie zrobiłeś tego w workerze
|
||||
self.frame_ready.emit(frame)
|
||||
46
app/logging_config.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# logging_config.py
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOG_DIR = Path("logs")
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
LOG_FILE = LOG_DIR / "app.log"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(threadName)s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# logger główny
|
||||
logger = logging.getLogger()
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# zabezpieczenie przed dodaniem handlerów drugi raz
|
||||
if logger.handlers:
|
||||
return
|
||||
|
||||
# console
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# plik
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
LOG_FILE,
|
||||
maxBytes=5_000_000,
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
logger.addHandler(console_handler)
|
||||
logger.addHandler(file_handler)
|
||||
188
app/main_window.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QMainWindow, QStyle, QToolButton, QWidget, QVBoxLayout, QPushButton
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtCore import Qt, QSize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class VideoMode(Enum):
|
||||
STREAMING = "streaming"
|
||||
RECORDING = "recording"
|
||||
PLAYING = "playing"
|
||||
STOPPED = "stopped"
|
||||
|
||||
icons = {
|
||||
"load_file":{
|
||||
"path": "assets/folder-svgrepo-com.svg",
|
||||
"tooltip": "Open File",
|
||||
"size": QSize(48, 48)
|
||||
},
|
||||
"close_file": {
|
||||
"path": "assets/close-square-svgrepo-com.svg",
|
||||
"tooltip": "Close File",
|
||||
"size": QSize(32, 32)
|
||||
},
|
||||
"record_on": {
|
||||
"path": "assets/record_btn_on.svg",
|
||||
"tooltip": "Start Recording",
|
||||
"size": QSize(48, 48)
|
||||
},
|
||||
"record_off": {
|
||||
"path": "assets/record_btn_off.svg",
|
||||
"tooltip": "Stop Recording",
|
||||
"size": QSize(48, 48)
|
||||
},
|
||||
|
||||
"play": {
|
||||
"path": "assets/play-svgrepo-com.svg",
|
||||
"tooltip": "Play Video",
|
||||
"size": QSize(48, 48)
|
||||
},
|
||||
"pause": {
|
||||
"path": "assets/pause-svgrepo-com.svg",
|
||||
"tooltip": "Pause Video",
|
||||
"size": QSize(32, 32)
|
||||
}
|
||||
}
|
||||
|
||||
def set_icon(button: QToolButton, icon: str):
|
||||
button.setIcon(QIcon(icons[icon]["path"]))
|
||||
button.setToolTip(icons[icon]["tooltip"])
|
||||
button.setIconSize(icons[icon]["size"])
|
||||
|
||||
|
||||
def create_tool_button(icon: str) -> QToolButton:
|
||||
button = QToolButton()
|
||||
set_icon(button, icon)
|
||||
button.setFixedSize(48, 48)
|
||||
button.setStyleSheet("background-color: transparent; border: none;")
|
||||
return button
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Duck Stain OCR")
|
||||
self.resize(1280, 720)
|
||||
|
||||
self.video_mode = VideoMode.STREAMING
|
||||
logger.debug(f"Initial video mode: {self.video_mode}")
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
self.central_widget = QWidget()
|
||||
self.setCentralWidget(self.central_widget)
|
||||
self.central_widget.setStyleSheet("background-color: #001e1e;")
|
||||
|
||||
self.toolbar_widget = QWidget(self.central_widget)
|
||||
self.toolbar_widget.setMinimumWidth(400)
|
||||
self.toolbar_widget.setObjectName("bottomToolbar")
|
||||
self.toolbar_widget.setStyleSheet(
|
||||
"""
|
||||
QWidget#bottomToolbar {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 18px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
left_layout = QHBoxLayout()
|
||||
right_layout = QHBoxLayout()
|
||||
|
||||
self.toolbar_layout = QGridLayout(self.toolbar_widget)
|
||||
self.toolbar_layout.setContentsMargins(18, 16, 18, 16)
|
||||
self.toolbar_layout.setColumnStretch(0, 1)
|
||||
self.toolbar_layout.setColumnStretch(1, 0)
|
||||
self.toolbar_layout.setColumnStretch(2, 1)
|
||||
|
||||
self.toolbar_layout.addLayout(left_layout, 0, 0)
|
||||
self.toolbar_layout.addLayout(right_layout, 0, 2)
|
||||
|
||||
# Add buttons to the left layout
|
||||
self.open_file_button = create_tool_button("load_file")
|
||||
self.open_file_button.clicked.connect(self.load_video)
|
||||
left_layout.addWidget(self.open_file_button)
|
||||
self.close_file_button = create_tool_button("close_file")
|
||||
self.close_file_button.clicked.connect(self.close_video)
|
||||
self.close_file_button.setEnabled(False)
|
||||
left_layout.addWidget(self.close_file_button)
|
||||
|
||||
left_layout.addStretch()
|
||||
|
||||
# Add the record button to the center of the toolbar
|
||||
self.action_button = create_tool_button("record_off")
|
||||
self.action_button.clicked.connect(self.toggle_action)
|
||||
self.toolbar_layout.addWidget(self.action_button, 0, 1, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Add buttons to the right layout
|
||||
right_layout.addStretch()
|
||||
|
||||
|
||||
def resizeEvent(self, event: Any) -> None:
|
||||
super().resizeEvent(event)
|
||||
|
||||
self.toolbar_widget.adjustSize()
|
||||
toolbar_size = self.toolbar_widget.sizeHint()
|
||||
self.toolbar_widget.setGeometry(
|
||||
(self.central_widget.width() - self.toolbar_widget.width()) // 2,
|
||||
self.central_widget.height() - self.toolbar_widget.height() - 80,
|
||||
self.toolbar_widget.width(),
|
||||
self.toolbar_widget.height(),
|
||||
)
|
||||
self.toolbar_widget.raise_() # Ensure the toolbar is above other widgets
|
||||
|
||||
def toggle_action(self):
|
||||
match self.video_mode:
|
||||
case VideoMode.STREAMING:
|
||||
self.start_recording()
|
||||
case VideoMode.RECORDING:
|
||||
self.stop_recording()
|
||||
case VideoMode.PLAYING:
|
||||
self.pause_video()
|
||||
case VideoMode.STOPPED:
|
||||
self.play_video()
|
||||
case _:
|
||||
pass
|
||||
|
||||
def start_recording(self):
|
||||
logger.debug("Starting recording")
|
||||
self.video_mode = VideoMode.RECORDING
|
||||
set_icon(self.action_button, "record_on")
|
||||
|
||||
self.open_file_button.setEnabled(False)
|
||||
self.close_file_button.setEnabled(False)
|
||||
|
||||
def stop_recording(self):
|
||||
logger.debug("Stopping recording")
|
||||
self.video_mode = VideoMode.STREAMING
|
||||
set_icon(self.action_button, "record_off")
|
||||
|
||||
self.open_file_button.setEnabled(True)
|
||||
self.close_file_button.setEnabled(False)
|
||||
|
||||
def load_video(self):
|
||||
logger.debug("Loading video")
|
||||
self.video_mode = VideoMode.STOPPED
|
||||
set_icon(self.action_button, "play")
|
||||
|
||||
self.close_file_button.setEnabled(True)
|
||||
|
||||
def close_video(self):
|
||||
logger.debug("Closing video")
|
||||
self.video_mode = VideoMode.STREAMING
|
||||
set_icon(self.action_button, "record_off")
|
||||
|
||||
self.close_file_button.setEnabled(False)
|
||||
|
||||
def play_video(self):
|
||||
logger.debug("Playing video")
|
||||
self.video_mode = VideoMode.PLAYING
|
||||
set_icon(self.action_button, "pause")
|
||||
|
||||
def pause_video(self):
|
||||
logger.debug("Pausing video")
|
||||
self.video_mode = VideoMode.STOPPED
|
||||
set_icon(self.action_button, "play")
|
||||
12
assets/circle.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24">
|
||||
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 210 B |
5
assets/close-square-svgrepo-com.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" stroke-width="3" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.00191 9.41621C7.61138 9.02569 7.61138 8.39252 8.00191 8.002C8.39243 7.61147 9.0256 7.61147 9.41612 8.002L12.0057 10.5916L14.5896 8.00771C14.9801 7.61719 15.6133 7.61719 16.0038 8.00771C16.3943 8.39824 16.3943 9.0314 16.0038 9.42193L13.4199 12.0058L16.0039 14.5897C16.3944 14.9803 16.3944 15.6134 16.0039 16.004C15.6133 16.3945 14.9802 16.3945 14.5896 16.004L12.0057 13.42L9.42192 16.0038C9.03139 16.3943 8.39823 16.3943 8.00771 16.0038C7.61718 15.6133 7.61718 14.9801 8.00771 14.5896L10.5915 12.0058L8.00191 9.41621Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 4C23 2.34315 21.6569 1 20 1H4C2.34315 1 1 2.34315 1 4V20C1 21.6569 2.34315 23 4 23H20C21.6569 23 23 21.6569 23 20V4Z" stroke="white" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 978 B |
4
assets/folder-svgrepo-com.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 8.2C3 7.07989 3 6.51984 3.21799 6.09202C3.40973 5.71569 3.71569 5.40973 4.09202 5.21799C4.51984 5 5.0799 5 6.2 5H9.67452C10.1637 5 10.4083 5 10.6385 5.05526C10.8425 5.10425 11.0376 5.18506 11.2166 5.29472C11.4184 5.4184 11.5914 5.59135 11.9373 5.93726L12.0627 6.06274C12.4086 6.40865 12.5816 6.5816 12.7834 6.70528C12.9624 6.81494 13.1575 6.89575 13.3615 6.94474C13.5917 7 13.8363 7 14.3255 7H17.8C18.9201 7 19.4802 7 19.908 7.21799C20.2843 7.40973 20.5903 7.71569 20.782 8.09202C21 8.51984 21 9.0799 21 10.2V15.8C21 16.9201 21 17.4802 20.782 17.908C20.5903 18.2843 20.2843 18.5903 19.908 18.782C19.4802 19 18.9201 19 17.8 19H6.2C5.07989 19 4.51984 19 4.09202 18.782C3.71569 18.5903 3.40973 18.2843 3.21799 17.908C3 17.4802 3 16.9201 3 15.8V8.2Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
5
assets/pause-svgrepo-com.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6V18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18V6Z" stroke="white" stroke-width="3"/>
|
||||
<path d="M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6V18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 19.8856 14 18V6Z" stroke="white" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 847 B |
4
assets/play-svgrepo-com.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6582 9.28638C18.098 10.1862 18.8178 10.6361 19.0647 11.2122C19.2803 11.7152 19.2803 12.2847 19.0647 12.7878C18.8178 13.3638 18.098 13.8137 16.6582 14.7136L9.896 18.94C8.29805 19.9387 7.49907 20.4381 6.83973 20.385C6.26501 20.3388 5.73818 20.0469 5.3944 19.584C5 19.053 5 18.1108 5 16.2264V7.77357C5 5.88919 5 4.94701 5.3944 4.41598C5.73818 3.9531 6.26501 3.66111 6.83973 3.6149C7.49907 3.5619 8.29805 4.06126 9.896 5.05998L16.6582 9.28638Z" stroke="white" stroke-width="2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 739 B |
12
assets/record_btn_off.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24">
|
||||
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 210 B |
12
assets/record_btn_on.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24">
|
||||
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="red" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 209 B |
0
captures/photos/.gitkeep
Normal file
0
captures/videos/.gitkeep
Normal file
21
duck-ocr.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from app.main_window import MainWindow
|
||||
from app.logging_config import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def main() -> None:
|
||||
logger.info("Starting application")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||