Compare commits
12 Commits
feebc5153a
...
review
| Author | SHA1 | Date | |
|---|---|---|---|
| 02edb186bb | |||
| 47c1e6040a | |||
| c8d9029df7 | |||
| 96c2495a8b | |||
| 03ab345e17 | |||
| d62b367b47 | |||
| bbdb7d3459 | |||
| fa84a29ab5 | |||
| 2cc496a2b2 | |||
| 0bc6c01e7e | |||
| 4d00f83083 | |||
| 2c1233f304 |
@@ -1,97 +0,0 @@
|
||||
# import gphoto2 as gp
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
from PySide6.QtCore import QObject, QThread, Signal
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
|
||||
# try:
|
||||
# import gphoto2 as gp
|
||||
# except:
|
||||
from . import mock_gphoto as gp
|
||||
|
||||
class CameraWorker(QObject):
|
||||
frameReady = Signal(QPixmap)
|
||||
errorOccurred = Signal(str)
|
||||
|
||||
def __init__(self, fps: int = 15, parent=None):
|
||||
super().__init__(parent)
|
||||
self.fps = fps
|
||||
self.running = False
|
||||
self.camera = None
|
||||
|
||||
def start_camera(self):
|
||||
"""Uruchom kamerę i zacznij pobierać klatki"""
|
||||
try:
|
||||
self.camera = gp.Camera() # type: ignore
|
||||
self.camera.init()
|
||||
self.running = True
|
||||
self._capture_loop()
|
||||
except gp.GPhoto2Error as e:
|
||||
self.errorOccurred.emit(f"Błąd inicjalizacji kamery: {e}")
|
||||
|
||||
def stop_camera(self):
|
||||
"""Zatrzymaj pobieranie"""
|
||||
self.running = False
|
||||
if self.camera:
|
||||
try:
|
||||
self.camera.exit()
|
||||
except gp.GPhoto2Error:
|
||||
pass
|
||||
self.camera = None
|
||||
|
||||
def _capture_loop(self):
|
||||
"""Pętla odczytu klatek w osobnym wątku"""
|
||||
import time
|
||||
delay = 1.0 / self.fps
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
file = self.camera.capture_preview() # type: ignore
|
||||
data = file.get_data_and_size()
|
||||
frame = np.frombuffer(data, dtype=np.uint8)
|
||||
frame = cv2.imdecode(frame, cv2.IMREAD_COLOR)
|
||||
|
||||
if frame is not None:
|
||||
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
h, w, ch = rgb_image.shape
|
||||
qimg = QImage(rgb_image.data, w, h, ch * w, QImage.Format.Format_RGB888)
|
||||
pixmap = QPixmap.fromImage(qimg)
|
||||
self.frameReady.emit(pixmap)
|
||||
|
||||
except gp.GPhoto2Error as e:
|
||||
self.errorOccurred.emit(f"Błąd odczytu LiveView: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
self.errorOccurred.emit(f"Nieoczekiwany błąd: {e}")
|
||||
break
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
class CameraController(QObject):
|
||||
frameReady = Signal(QPixmap)
|
||||
errorOccurred = Signal(str)
|
||||
|
||||
def __init__(self, fps: int = 15, parent=None):
|
||||
super().__init__(parent)
|
||||
self.camera_thread = QThread()
|
||||
self.worker = CameraWorker(fps)
|
||||
self.worker.moveToThread(self.camera_thread )
|
||||
|
||||
# sygnały z workera
|
||||
self.worker.frameReady.connect(self.frameReady)
|
||||
self.worker.errorOccurred.connect(self.errorOccurred)
|
||||
|
||||
# sygnały start/stop
|
||||
self.camera_thread.started.connect(self.worker.start_camera)
|
||||
|
||||
def start(self):
|
||||
"""Start kamery w osobnym wątku"""
|
||||
self.camera_thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop kamery i zakończenie wątku"""
|
||||
self.worker.stop_camera()
|
||||
self.camera_thread.quit()
|
||||
self.camera_thread.wait()
|
||||
@@ -3,26 +3,32 @@ from PySide6.QtCore import Slot
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
from core.database import DatabaseManager
|
||||
from core.database import db_manager
|
||||
from core.media import MediaRepository
|
||||
from core.camera.camera_manager import CameraManager
|
||||
from ui.widgets.color_list_widget import ColorListWidget
|
||||
from ui.widgets.thumbnail_list_widget import ThumbnailListWidget
|
||||
from ui.widgets.split_view_widget import SplitView, CameraPlaceholder
|
||||
from ui.widgets.split_view_widget import SplitView, CameraPlaceholder, ViewWithOverlay
|
||||
from core.camera.states import CameraState, NoCamerasState, ReadyToStreamState, StreamingState, DetectingState
|
||||
|
||||
|
||||
class MainController:
|
||||
def __init__(self, view):
|
||||
self.view = view
|
||||
self.db = DatabaseManager()
|
||||
self.media_repo = MediaRepository(self.db)
|
||||
self.db = db_manager
|
||||
self.media_repo = MediaRepository()
|
||||
self.camera_manager = CameraManager()
|
||||
|
||||
# --- State ---
|
||||
self.selected_color_name: str | None = None
|
||||
self._latest_pixmap: QPixmap | None = None
|
||||
|
||||
# --- UI Widgets ---
|
||||
self.color_list: ColorListWidget = view.color_list_widget
|
||||
self.thumbnail_list: ThumbnailListWidget = view.thumbnail_widget
|
||||
self.split_view: SplitView = view.preview_widget
|
||||
self.welcome_view: CameraPlaceholder = self.split_view.widget_start
|
||||
self.live_view: ViewWithOverlay = self.split_view.widget_live
|
||||
self.photo_button: QPushButton = view.photo_button
|
||||
self.record_button: QPushButton = view.record_button
|
||||
|
||||
@@ -30,7 +36,19 @@ class MainController:
|
||||
|
||||
self.db.connect()
|
||||
self.media_repo.sync_media()
|
||||
self.camera_manager.detect_cameras()
|
||||
|
||||
# Disable button by default
|
||||
self.photo_button.setEnabled(False)
|
||||
|
||||
# Initialize state machine
|
||||
self.state: CameraState = NoCamerasState()
|
||||
self.state.enter_state(self)
|
||||
|
||||
def transition_to(self, new_state: CameraState):
|
||||
"""Transitions the controller to a new state."""
|
||||
print(f"Transitioning to state: {new_state.__class__.__name__}")
|
||||
self.state = new_state
|
||||
self.state.enter_state(self)
|
||||
|
||||
def _connect_signals(self):
|
||||
"""Connects all signals to slots."""
|
||||
@@ -48,10 +66,9 @@ class MainController:
|
||||
|
||||
# UI control signals
|
||||
self.photo_button.clicked.connect(self.take_photo)
|
||||
# self.record_button.clicked.connect(self.toggle_record) # Placeholder
|
||||
self.welcome_view.camera_start_btn.clicked.connect(self.start_liveview)
|
||||
# You will need a way to select a camera, e.g., a combobox.
|
||||
# self.view.camera_combobox.currentIndexChanged.connect(self.on_camera_selected_in_ui)
|
||||
self.welcome_view.camera_start_btn.clicked.connect(self._on_start_button_clicked)
|
||||
self.live_view.rotateCW.connect(self.camera_manager.rotate_right)
|
||||
self.live_view.rotateCCW.connect(self.camera_manager.rotate_left)
|
||||
|
||||
def load_colors(self) -> None:
|
||||
"""Loads colors from the database and populates the list."""
|
||||
@@ -67,6 +84,9 @@ class MainController:
|
||||
|
||||
@Slot(str)
|
||||
def on_color_selected(self, color_name: str):
|
||||
self.selected_color_name = color_name
|
||||
self.photo_button.setEnabled(True)
|
||||
|
||||
color_id = self.db.get_color_id(color_name)
|
||||
if color_id is not None:
|
||||
media_items = self.db.get_media_for_color(color_id)
|
||||
@@ -90,46 +110,51 @@ class MainController:
|
||||
|
||||
@Slot(list)
|
||||
def on_cameras_detected(self, cameras: list[dict]):
|
||||
"""Handles the list of detected cameras."""
|
||||
"""Handles the list of detected cameras and transitions state."""
|
||||
print("Detected cameras:", cameras)
|
||||
self.welcome_view.set_info_text(f"Detected {len(cameras)} cameras.")
|
||||
# Populate a combobox in the UI here
|
||||
# self.view.camera_combobox.clear()
|
||||
# for camera in cameras:
|
||||
# self.view.camera_combobox.addItem(camera['name'], userData=camera['id'])
|
||||
if cameras:
|
||||
self.transition_to(ReadyToStreamState())
|
||||
else:
|
||||
self.transition_to(NoCamerasState())
|
||||
|
||||
@Slot(QPixmap)
|
||||
def on_frame_ready(self, pixmap: QPixmap):
|
||||
"""Displays a new frame from the camera."""
|
||||
"""Displays a new frame from the camera and stores it."""
|
||||
self._latest_pixmap = pixmap
|
||||
self.split_view.set_live_image(pixmap)
|
||||
|
||||
@Slot(str)
|
||||
def on_camera_error(self, error_message: str):
|
||||
"""Shows an error message from the camera."""
|
||||
"""Shows an error message from the camera and transitions to a safe state."""
|
||||
print(f"Camera Error: {error_message}")
|
||||
self.welcome_view.set_error_text(error_message)
|
||||
self.transition_to(NoCamerasState())
|
||||
|
||||
@Slot()
|
||||
def on_camera_started(self):
|
||||
"""Updates UI when the camera stream starts."""
|
||||
self.split_view.toggle_live_view()
|
||||
self.welcome_view.set_button_text("Stop Camera")
|
||||
# Re-route button click to stop the camera
|
||||
self.welcome_view.camera_start_btn.clicked.disconnect()
|
||||
self.welcome_view.camera_start_btn.clicked.connect(self.stop_liveview)
|
||||
|
||||
"""Transitions to StreamingState when the camera starts."""
|
||||
self.transition_to(StreamingState())
|
||||
|
||||
@Slot()
|
||||
def on_camera_stopped(self):
|
||||
"""Updates UI when the camera stream stops."""
|
||||
# self.split_view.show_placeholder()
|
||||
self.welcome_view.set_button_text("Start Camera")
|
||||
# Re-route button click to start the camera
|
||||
self.welcome_view.camera_start_btn.clicked.disconnect()
|
||||
self.welcome_view.camera_start_btn.clicked.connect(self.start_liveview)
|
||||
"""Transitions to a post-streaming state."""
|
||||
self.split_view.toggle_live_view()
|
||||
if self.camera_manager.get_detected_cameras():
|
||||
self.transition_to(ReadyToStreamState())
|
||||
else:
|
||||
self.transition_to(NoCamerasState())
|
||||
|
||||
# --- UI Actions ---
|
||||
|
||||
def _on_start_button_clicked(self):
|
||||
"""Delegates the button click to the current state."""
|
||||
self.state.handle_start_button(self)
|
||||
|
||||
def camera_detect(self):
|
||||
"""Initiates camera detection and transitions to DetectingState."""
|
||||
self.transition_to(DetectingState())
|
||||
self.camera_manager.detect_cameras()
|
||||
|
||||
def start_liveview(self):
|
||||
"""Starts the camera feed."""
|
||||
detected_cameras = self.camera_manager.get_detected_cameras()
|
||||
@@ -137,8 +162,6 @@ class MainController:
|
||||
self.on_camera_error("No cameras detected.")
|
||||
return
|
||||
|
||||
# For now, just start the first detected camera.
|
||||
# In a real app, you'd get the selected camera ID from the UI.
|
||||
camera_id = detected_cameras[0]['id']
|
||||
self.camera_manager.start_camera(camera_id)
|
||||
|
||||
@@ -148,7 +171,17 @@ class MainController:
|
||||
|
||||
def take_photo(self):
|
||||
"""Takes a photo with the active camera."""
|
||||
print("Taking photo...") # Placeholder
|
||||
# This needs to be implemented in CameraManager and called here.
|
||||
# e.g., self.camera_manager.take_photo()
|
||||
self.split_view.toggle_live_view() # This seems like a UI toggle, maybe rename?
|
||||
if self.selected_color_name is None:
|
||||
print("Cannot take photo: No color selected.")
|
||||
# Optionally: show a message to the user in the UI
|
||||
return
|
||||
|
||||
if self._latest_pixmap is None or self._latest_pixmap.isNull():
|
||||
print("Cannot take photo: No frame available.")
|
||||
return
|
||||
|
||||
print(f"Taking photo for color: {self.selected_color_name}")
|
||||
self.media_repo.save_photo(self._latest_pixmap, self.selected_color_name)
|
||||
|
||||
# Refresh thumbnail list
|
||||
self.on_color_selected(self.selected_color_name)
|
||||
@@ -1,148 +0,0 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
GP_WIDGET_WINDOW = 0
|
||||
GP_WIDGET_SECTION = 0
|
||||
GP_WIDGET_TEXT = 0
|
||||
GP_WIDGET_RANGE = 0
|
||||
GP_WIDGET_TOGGLE = 0
|
||||
GP_WIDGET_RADIO = 0
|
||||
GP_WIDGET_MENU = 0
|
||||
GP_WIDGET_BUTTON = 0
|
||||
GP_WIDGET_DATE = 0
|
||||
|
||||
class GPhoto2Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CameraFileMock:
|
||||
"""Mock obiektu zwracanego przez gphoto2.Camera.capture_preview()"""
|
||||
|
||||
def __init__(self, frame: np.ndarray):
|
||||
# Kodowanie do JPEG, żeby symulować prawdziwe dane z kamery
|
||||
success, buf = cv2.imencode(".jpg", frame)
|
||||
if not success:
|
||||
raise GPhoto2Error("Nie udało się zakodować ramki testowej.")
|
||||
self._data = buf.tobytes()
|
||||
|
||||
def get_data_and_size(self):
|
||||
return self._data
|
||||
return self._data, len(self._data)
|
||||
|
||||
class CameraListMock:
|
||||
def count(self):
|
||||
return 1
|
||||
|
||||
def get_name(self, idx):
|
||||
return f"mock_name {idx}"
|
||||
|
||||
def get_value(self, idx):
|
||||
return f"mock_value {idx}"
|
||||
|
||||
class MockPortInfo:
|
||||
def __init__(self, address):
|
||||
self.address = address
|
||||
|
||||
class PortInfoList:
|
||||
def __init__(self):
|
||||
self._ports = []
|
||||
|
||||
def load(self):
|
||||
# Dodaj przykładowe porty
|
||||
self._ports = [MockPortInfo("usb:001,002"), MockPortInfo("usb:001,003")]
|
||||
|
||||
def lookup_path(self, port_address):
|
||||
for idx, port in enumerate(self._ports):
|
||||
if port.address == port_address:
|
||||
return idx
|
||||
raise ValueError("Port not found")
|
||||
|
||||
def __getitem__(self, idx):
|
||||
return self._ports[idx]
|
||||
|
||||
class ConfigMock:
|
||||
def get_id(self):
|
||||
return 0
|
||||
def get_name(self):
|
||||
return "name"
|
||||
def get_label(self):
|
||||
return "label"
|
||||
def get_type(self):
|
||||
return 0
|
||||
def get_value(self):
|
||||
return "value"
|
||||
def get_choices(self):
|
||||
return []
|
||||
def count_children(self):
|
||||
return 0
|
||||
def get_child(self):
|
||||
return ConfigMock()
|
||||
|
||||
|
||||
class CameraAbilitiesList:
|
||||
def __init__(self) -> None:
|
||||
self.abilities = []
|
||||
def load(self):
|
||||
return
|
||||
def lookup_model(self, name):
|
||||
return 1
|
||||
def get_abilities(self, abilities_index):
|
||||
return 0
|
||||
|
||||
class Camera:
|
||||
def __init__(self):
|
||||
self._frame_counter = 0
|
||||
self._running = False
|
||||
|
||||
def init(self):
|
||||
self._running = True
|
||||
print("[my_gphoto] Kamera MOCK zainicjalizowana")
|
||||
|
||||
def exit(self):
|
||||
self._running = False
|
||||
print("[my_gphoto] Kamera MOCK wyłączona")
|
||||
|
||||
def capture_preview(self):
|
||||
if not self._running:
|
||||
raise GPhoto2Error("Kamera MOCK nie jest uruchomiona")
|
||||
|
||||
# przykład 1: wczytaj stały obrazek z pliku
|
||||
# frame = cv2.imread("test_frame.jpg")
|
||||
# if frame is None:
|
||||
# raise GPhoto2Error("Nie znaleziono test_frame.jpg")
|
||||
|
||||
# przykład 2: wygeneruj kolorową planszę
|
||||
h, w = 480, 640
|
||||
color = (self._frame_counter % 255, 100, 200)
|
||||
frame = np.full((h, w, 3), color, dtype=np.uint8)
|
||||
|
||||
# dodanie napisu
|
||||
text = "OBRAZ TESTOWY"
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
scale = 1.5
|
||||
thickness = 3
|
||||
color_text = (255, 255, 255)
|
||||
|
||||
(text_w, text_h), _ = cv2.getTextSize(text, font, scale, thickness)
|
||||
x = (w - text_w) // 2
|
||||
y = (h + text_h) // 2
|
||||
cv2.putText(frame, text, (x, y), font, scale, color_text, thickness, cv2.LINE_AA)
|
||||
|
||||
|
||||
self._frame_counter += 1
|
||||
return CameraFileMock(frame)
|
||||
|
||||
def set_port_info(self, obj):
|
||||
return False
|
||||
|
||||
def get_config(self):
|
||||
return ConfigMock()
|
||||
|
||||
def set_single_config(self, name, widget):
|
||||
return True
|
||||
|
||||
def gp_camera_autodetect():
|
||||
return CameraListMock()
|
||||
|
||||
def check_result(obj):
|
||||
return obj
|
||||
@@ -18,12 +18,13 @@ class CameraWorker(QObject):
|
||||
self.fps = 15
|
||||
self.is_streaming = False
|
||||
self.is_connected = False
|
||||
self._rotation_index = 0
|
||||
self._camera_mutex = QMutex()
|
||||
|
||||
@Slot()
|
||||
def initialize_worker(self):
|
||||
"""Initializes the timer in the worker's thread."""
|
||||
self.timer = QTimer()
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self._update_frame)
|
||||
|
||||
@Slot(BaseCamera, int)
|
||||
@@ -93,7 +94,16 @@ class CameraWorker(QObject):
|
||||
self.error_occurred.emit(error_msg)
|
||||
return
|
||||
|
||||
if frame is not None:
|
||||
if frame is None:
|
||||
return
|
||||
|
||||
if self._rotation_index == 1:
|
||||
frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
|
||||
elif self._rotation_index == 2:
|
||||
frame = cv2.rotate(frame, cv2.ROTATE_180)
|
||||
elif self._rotation_index == 3:
|
||||
frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||
|
||||
# Process the frame and emit it.
|
||||
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
h, w, ch = rgb_image.shape
|
||||
@@ -101,6 +111,14 @@ class CameraWorker(QObject):
|
||||
pixmap = QPixmap.fromImage(qimg)
|
||||
self.frame_ready.emit(pixmap)
|
||||
|
||||
@Slot()
|
||||
def rotate_left(self):
|
||||
self._rotation_index = (self._rotation_index - 1) % 4
|
||||
|
||||
@Slot()
|
||||
def rotate_right(self):
|
||||
self._rotation_index = (self._rotation_index + 1) % 4
|
||||
|
||||
|
||||
|
||||
class CameraController(QObject):
|
||||
@@ -115,6 +133,8 @@ class CameraController(QObject):
|
||||
_stop_camera_requested = Signal()
|
||||
_start_stream_requested = Signal()
|
||||
_stop_stream_requested = Signal()
|
||||
_rotate_left_requested = Signal()
|
||||
_rotate_right_requested = Signal()
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -135,15 +155,20 @@ class CameraController(QObject):
|
||||
self._stop_camera_requested.connect(self._worker.stop_camera)
|
||||
self._start_stream_requested.connect(self._worker.start_stream)
|
||||
self._stop_stream_requested.connect(self._worker.stop_stream)
|
||||
self._rotate_left_requested.connect(self._worker.rotate_left)
|
||||
self._rotate_right_requested.connect(self._worker.rotate_right)
|
||||
|
||||
# Initialize worker when thread starts
|
||||
self._thread.started.connect(self._worker.initialize_worker)
|
||||
self._thread.finished.connect(self._worker.stop_camera)
|
||||
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
if self._thread.isRunning():
|
||||
self._thread.quit()
|
||||
self._thread.wait()
|
||||
self._thread.deleteLater()
|
||||
|
||||
def set_camera(self, camera: BaseCamera, fps: int = 15) -> None:
|
||||
self._set_camera_requested.emit(camera, fps)
|
||||
@@ -160,3 +185,10 @@ class CameraController(QObject):
|
||||
def stop_stream(self) -> None:
|
||||
self._stop_stream_requested.emit()
|
||||
|
||||
def rotate_left(self):
|
||||
self._rotate_left_requested.emit()
|
||||
|
||||
def rotate_right(self):
|
||||
self._rotate_right_requested.emit()
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@ class CameraManager(QObject):
|
||||
def get_active_camera_info(self) -> dict | None:
|
||||
return self._active_camera_info
|
||||
|
||||
def rotate_left(self):
|
||||
self._camera_controller.rotate_left()
|
||||
|
||||
def rotate_right(self):
|
||||
self._camera_controller.rotate_right()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Zamyka kontroler kamery i jego wątek."""
|
||||
self.stop_camera()
|
||||
|
||||
57
core/camera/states.py
Normal file
57
core/camera/states.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from controllers.main_controller import MainController
|
||||
|
||||
class CameraState(ABC):
|
||||
"""Abstract base class for all camera states."""
|
||||
|
||||
def enter_state(self, controller: MainController):
|
||||
"""Called upon entering the state, e.g., to update the UI."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def handle_start_button(self, controller: MainController):
|
||||
"""Handles the main camera button click."""
|
||||
pass
|
||||
|
||||
class NoCamerasState(CameraState):
|
||||
def enter_state(self, controller: MainController):
|
||||
controller.welcome_view.set_button_text("Wykryj kamery")
|
||||
controller.welcome_view.set_info_text("Nie wykryto kamer. Kliknij, aby rozpocząć.")
|
||||
controller.welcome_view.camera_start_btn.setEnabled(True)
|
||||
|
||||
def handle_start_button(self, controller: MainController):
|
||||
controller.camera_detect()
|
||||
|
||||
class DetectingState(CameraState):
|
||||
def enter_state(self, controller: MainController):
|
||||
controller.welcome_view.set_button_text("Wykrywanie...")
|
||||
controller.welcome_view.set_info_text("Trwa wykrywanie kamer...")
|
||||
controller.welcome_view.camera_start_btn.setEnabled(False)
|
||||
|
||||
def handle_start_button(self, controller: MainController):
|
||||
# Do nothing while detecting
|
||||
pass
|
||||
|
||||
class ReadyToStreamState(CameraState):
|
||||
def enter_state(self, controller: MainController):
|
||||
cameras = controller.camera_manager.get_detected_cameras()
|
||||
controller.welcome_view.set_button_text("Uruchom kamerę")
|
||||
controller.welcome_view.set_info_text(f"Wykryto {len(cameras)} kamer(y).")
|
||||
controller.welcome_view.camera_start_btn.setEnabled(True)
|
||||
|
||||
def handle_start_button(self, controller: MainController):
|
||||
controller.start_liveview()
|
||||
|
||||
class StreamingState(CameraState):
|
||||
def enter_state(self, controller: MainController):
|
||||
controller.welcome_view.set_button_text("Zatrzymaj kamerę")
|
||||
controller.welcome_view.camera_start_btn.setEnabled(True)
|
||||
if controller.split_view.stack.currentWidget() != controller.live_view:
|
||||
controller.split_view.toggle_live_view()
|
||||
|
||||
def handle_start_button(self, controller: MainController):
|
||||
controller.stop_liveview()
|
||||
@@ -154,3 +154,6 @@ class DatabaseManager:
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("DELETE FROM media WHERE color_id = ?", (color_id,))
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from core.database import DatabaseManager
|
||||
from datetime import datetime
|
||||
from PySide6.QtGui import QPixmap
|
||||
from core.database import db_manager
|
||||
|
||||
MEDIA_DIR = Path("media")
|
||||
DEFAULT_ICON = Path("media/default_icon.jpg")
|
||||
|
||||
class MediaRepository:
|
||||
def __init__(self, db: DatabaseManager):
|
||||
self.db = db
|
||||
def __init__(self):
|
||||
self.db = db_manager
|
||||
|
||||
def save_photo(self, pixmap: QPixmap, color_name: str):
|
||||
"""Saves a pixmap to the media directory for the given color."""
|
||||
color_id = self.db.get_color_id(color_name)
|
||||
if color_id is None:
|
||||
print(f"Error: Could not find color ID for {color_name}")
|
||||
return
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
color_dir = MEDIA_DIR / color_name
|
||||
color_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
filename = f"photo_{timestamp}.jpg"
|
||||
file_path = color_dir / filename
|
||||
|
||||
# Save the pixmap
|
||||
if not pixmap.save(str(file_path), "JPG", 95):
|
||||
print(f"Error: Failed to save pixmap to {file_path}")
|
||||
return
|
||||
|
||||
# Add to database
|
||||
self.db.add_media(color_id, str(file_path), 'photo')
|
||||
print(f"Saved photo to {file_path}")
|
||||
|
||||
def sync_media(self):
|
||||
disk_colors = {d.name for d in MEDIA_DIR.iterdir() if d.is_dir()}
|
||||
|
||||
72
review.md
Normal file
72
review.md
Normal file
@@ -0,0 +1,72 @@
|
||||
Przejrzałem kod Twojej aplikacji. Gratuluję, to bardzo dobrze zorganizowany projekt, zwłaszcza jak na aplikację, która jest jeszcze w trakcie rozwoju. Widać dużą dbałość o architekturę i separację poszczególnych warstw.
|
||||
|
||||
Poniżej znajdziesz moje uwagi podzielone na kategorie, o które prosiłeś.
|
||||
|
||||
### 1. Architektura Aplikacji
|
||||
|
||||
Twoja architektura jest największym plusem projektu.
|
||||
|
||||
* **Wzorzec MVC/Controller:** Podział na foldery `core` (Model), `ui` (View) i `controllers` (Controller) jest świetnym zastosowaniem tego wzorca. `MainController` dobrze pełni rolę pośrednika, oddzielając logikę biznesową od interfejsu użytkownika.
|
||||
* **Podsystem kamery (`core/camera`):** To jest wzorowo zaprojektowana część aplikacji.
|
||||
* **Fasada (`CameraManager`):** Użycie `CameraManager` jako fasady, która ukrywa skomplikowaną logikę wyboru i zarządzania kamerą, jest doskonałym pomysłem. Upraszcza to reszcie aplikacji interakcję z kamerami.
|
||||
* **Strategia (`BaseCamera`):** Abstrakcyjna klasa `BaseCamera` i jej konkretne implementacje (`GPhotoCamera`, `OpenCvCamera`) to świetne użycie wzorca strategii. Pozwala to na łatwe dodawanie nowych typów kamer w przyszłości.
|
||||
* **Wątkowość:** Przeniesienie obsługi kamery do osobnego wątku (`CameraWorker` i `QThread`) jest kluczowe dla aplikacji Qt i zostało zrobione poprawnie. Dzięki temu interfejs użytkownika nie zawiesza się podczas przechwytywania obrazu.
|
||||
|
||||
* **Problem do rozwiązania:**
|
||||
* **Zduplikowane pliki:** Zauważyłem, że w projekcie istnieją dwa pliki o nazwie `camera_controller.py` (w `controllers/` i `core/camera/`) oraz dwa pliki `mock_gphoto.py`. Wygląda na to, że te w folderze `controllers/` są starszymi, nieużywanymi wersjami. **Sugeruję ich usunięcie**, aby uniknąć pomyłek w przyszłości. Nowsze wersje w `core/camera/` są znacznie bardziej rozbudowane i bezpieczniejsze (np. używają `QMutex`).
|
||||
|
||||
### 2. Co można napisać lepiej? (Sugestie i Ulepszenia)
|
||||
|
||||
* **Zarządzanie ścieżkami i zasobami:**
|
||||
* W kodzie UI (np. w `ui/widgets/split_view_widget.py`) ścieżki do ikon są wpisane na stałe (np. `"ui/icons/rotate-cw-svgrepo-com.svg"`). To może powodować problemy, jeśli uruchomisz aplikację z innego katalogu.
|
||||
* **Sugestia:** Użyj biblioteki `pathlib` w połączeniu z globalną stałą, aby tworzyć absolutne ścieżki do zasobów. Możesz stworzyć plik `settings.py` lub `config.py`, w którym zdefiniujesz główny katalog aplikacji i podkatalogi z zasobami.
|
||||
|
||||
* **Logika rotacji obrazu referencyjnego:**
|
||||
* W `SplitView.rotate_left()` i `rotate_right()`, transformujesz pixmapę, która mogła być już wcześniej obrócona (`self.ref_pixmap = self.ref_pixmap.transformed(...)`). Każda taka operacja może prowadzić do utraty jakości obrazu.
|
||||
* **Sugestia:** Przechowuj oryginalny, niezmodyfikowany `QPixmap` w osobnej zmiennej. Przy każdej rotacji transformuj ten oryginalny obraz o łączny kąt obrotu, a nie wynik poprzedniej transformacji.
|
||||
|
||||
* **Upraszczanie logiki sygnałów w `MainController`:**
|
||||
* W metodzie `on_cameras_detected` i innych, wielokrotnie rozłączasz i podłączasz sygnały do slotów przycisku (`camera_start_btn.clicked.disconnect()`). Jest to podejście podatne na błędy.
|
||||
* **Sugestia:** Zamiast tego, użyj jednego slotu, który w środku sprawdza aktualny stan aplikacji (np. `if self.camera_manager.is_camera_active(): ...`). Możesz też po prostu zmieniać tekst i stan (`setEnabled`) przycisków w zależności od kontekstu.
|
||||
|
||||
* **Obsługa błędów:**
|
||||
* W niektórych miejscach (np. `GPhotoCamera.detect`) używasz szerokiego `except Exception as e:`. Warto łapać bardziej specyficzne wyjątki (np. `gp.GPhoto2Error`), aby lepiej reagować na konkretne problemy.
|
||||
|
||||
### 3. Wzorce projektowe do rozważenia
|
||||
|
||||
Używasz już wielu dobrych wzorców (Fasada, Strategia, Obserwator przez sygnały/sloty, Worker Thread). Oto kilka dodatkowych, które mogłyby się przydać:
|
||||
|
||||
* **Singleton:** Klasa `DatabaseManager` jest idealnym kandydatem na Singletona. W całej aplikacji potrzebujesz tylko jednego połączenia z bazą danych. Można to zrealizować tworząc jedną, globalną instancję w module `database.py`, którą inne części aplikacji będą importować.
|
||||
* **State (Stan):** Logika związana ze stanem kamery (brak kamery, wykryto, działa, zatrzymana) w `MainController` mogłaby zostać zamknięta we wzorcu Stan. Miałbyś obiekty reprezentujące każdy stan (np. `NoCameraState`, `StreamingState`), a każdy z nich inaczej obsługiwałby te same akcje (np. kliknięcie przycisku "Start"). To oczyściłoby kod z wielu instrukcji `if/else`.
|
||||
|
||||
### Podsumowanie i konkretne kroki
|
||||
|
||||
To bardzo obiecujący projekt z solidnymi fundamentami. Moje sugestie mają na celu głównie "doszlifowanie" istniejącego kodu.
|
||||
|
||||
**Zalecane działania (w kolejności od najważniejszych):**
|
||||
|
||||
1. **Wyczyść projekt:** Usuń zduplikowane pliki `controllers/camera_controller.py` i `controllers/mock_gphoto.py`, aby uniknąć nieporozumień.
|
||||
2. **Zrefaktoryzuj ścieżki:** Popraw sposób zarządzania ścieżkami do ikon i innych zasobów, aby uniezależnić się od bieżącego katalogu roboczego.
|
||||
3. **Popraw rotację obrazu:** Zmodyfikuj logikę w `SplitView`, aby uniknąć degradacji jakości obrazu przy wielokrotnym obracaniu.
|
||||
4. **Uprość logikę UI:** Zastanów się nad refaktoryzacją obsługi stanu przycisków w `MainController`, aby kod był bardziej czytelny i mniej podatny na błędy.
|
||||
|
||||
Świetna robota! Jeśli masz więcej pytań lub chciałbyś, żebym przyjrzał się jakiemuś konkretnemu fragmentowi, daj znać.
|
||||
|
||||
---
|
||||
|
||||
### Postęp Prac (14.10.2025)
|
||||
|
||||
Na podstawie powyższej recenzji, wspólnie wprowadziliśmy następujące zmiany:
|
||||
|
||||
* **Zrefaktoryzowano ścieżki do zasobów (Zrealizowano):**
|
||||
* Utworzono plik `settings.py` do centralnego zarządzania ścieżkami.
|
||||
* Zaktualizowano komponenty UI (`split_view_widget.py`, `view_settings_dialog.py`), aby korzystały ze scentralizowanych ścieżek, co uniezależniło aplikację od katalogu roboczego.
|
||||
|
||||
* **Poprawiono logikę rotacji obrazu (Zrealizowano):**
|
||||
* Zmieniono mechanizm obracania obrazu referencyjnego w `SplitView`, aby operacje były wykonywane na oryginalnym obrazie. Zapobiega to stopniowej utracie jakości przy wielokrotnych rotacjach.
|
||||
|
||||
* **Uproszczono logikę sygnałów w `MainController` (Zrealizowano):**
|
||||
* Zastąpiono dynamiczne łączenie i rozłączanie sygnałów przycisku kamery jednym, stałym połączeniem i centralną metodą obsługi. Zwiększyło to czytelność i niezawodność kodu.
|
||||
|
||||
* **Wyczyszczono projekt (Zrealizowano):**
|
||||
* Użytkownik potwierdził usunięcie zduplikowanych plików (`camera_controller.py` i `mock_gphoto.py`) z katalogu `controllers`.
|
||||
13
settings.py
Normal file
13
settings.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
# Absolutna ścieżka do głównego katalogu projektu
|
||||
ROOT_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
# Ścieżka do katalogu UI
|
||||
UI_DIR = ROOT_DIR / "ui"
|
||||
|
||||
# Ścieżka do katalogu z ikonami
|
||||
ICONS_DIR = UI_DIR / "icons"
|
||||
|
||||
# Ścieżka do katalogu z mediami
|
||||
MEDIA_DIR = ROOT_DIR / "media"
|
||||
@@ -2,6 +2,8 @@ from PySide6.QtWidgets import QDialog, QHBoxLayout ,QVBoxLayout, QPushButton, QG
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
|
||||
from settings import ICONS_DIR
|
||||
|
||||
ISO_ARR = ["AUTO","100", "200", "400", "800", "1600", "3200"]
|
||||
SPEED_ARR = ["30", "25", "20", "15", "13", "10.3", "8", "6.3", "5", "4", "3.2", "2.5", "2", "1.6", "1.3", "1", "0.8", "0.6", "0.5", "0.4", "0.3", "1/4", "1/5", "1/6", "1/8", "1/10", "1/13", "1/15", "1/20", "1/25", "1/30", "1/40", "1/50", "1/60", "1/80", "1/100", "1/125", "1/160", "1/200", "1/250", "1/320", "1/400", "1/500", "1/640", "1/800", "1/1000", "1/1250", "1/1600", "1/2000", "1/2500", "1/3200", "1/4000"]
|
||||
|
||||
@@ -19,13 +21,13 @@ class LabeledSpinSelector(QWidget):
|
||||
self.title_label = QLabel(title)
|
||||
|
||||
decrement_button = QToolButton()
|
||||
decrement_button.setIcon(QIcon("ui/icons/arrow-left-335-svgrepo-com.svg"))
|
||||
decrement_button.setIcon(QIcon(str(ICONS_DIR / "arrow-left-335-svgrepo-com.svg")))
|
||||
decrement_button.setFixedSize(button_size, button_size)
|
||||
decrement_button.setIconSize(QSize(icon_size, icon_size))
|
||||
decrement_button.clicked.connect(self._decrement)
|
||||
|
||||
increment_button = QToolButton()
|
||||
increment_button.setIcon(QIcon("ui/icons/arrow-right-336-svgrepo-com.svg"))
|
||||
increment_button.setIcon(QIcon(str(ICONS_DIR / "arrow-right-336-svgrepo-com.svg")))
|
||||
increment_button.setFixedSize(button_size, button_size)
|
||||
increment_button.setIconSize(QSize(icon_size, icon_size))
|
||||
increment_button.clicked.connect(self._increment)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QApplication, QMainWindow, QWidget, QVBoxLayout, QSplitter, QStackedWidget, QPushButton, QLabel, QToolButton
|
||||
from PySide6.QtGui import QEnterEvent, QPixmap, QWheelEvent, QPainter, QBrush, QColor, QIcon
|
||||
from PySide6.QtGui import QEnterEvent, QPixmap, QWheelEvent, QPainter, QBrush, QColor, QIcon, QImage, QTransform
|
||||
from PySide6.QtCore import Qt, QSize, Signal, QEvent
|
||||
import sys
|
||||
from ui.widgets.placeholder_widget import PlaceholderWidget
|
||||
from settings import ICONS_DIR
|
||||
|
||||
|
||||
class ZoomableImageView(QGraphicsView):
|
||||
@@ -33,10 +34,14 @@ class ZoomableImageView(QGraphicsView):
|
||||
self._current_scale = 1.0
|
||||
|
||||
def set_image(self, pixmap: QPixmap):
|
||||
# pixmap = QPixmap(image_path)
|
||||
if pixmap.isNull():
|
||||
return
|
||||
|
||||
self._pixmap_item.setPixmap(pixmap)
|
||||
self._scene.setSceneRect(pixmap.rect())
|
||||
# self.reset_transform()
|
||||
if self._current_scale == 1.0:
|
||||
self.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
self._first_image = False
|
||||
|
||||
def reset_transform(self):
|
||||
"""Resetuje skalowanie i ustawia 1:1"""
|
||||
@@ -175,12 +180,12 @@ class ViewWithOverlay(QWidget):
|
||||
|
||||
def _create_top_right_buttons(self):
|
||||
self.cw_btn = self._create_tool_button(
|
||||
icon_path="ui/icons/rotate-cw-svgrepo-com.svg",
|
||||
icon_path=str(ICONS_DIR / "rotate-cw-svgrepo-com.svg"),
|
||||
callback=self.rotateCW,
|
||||
)
|
||||
|
||||
self.ccw_btn = self._create_tool_button(
|
||||
icon_path="ui/icons/rotate-ccw-svgrepo-com.svg",
|
||||
icon_path=str(ICONS_DIR / "rotate-ccw-svgrepo-com.svg"),
|
||||
callback=self.rotateCCW,
|
||||
)
|
||||
|
||||
@@ -196,12 +201,12 @@ class ViewWithOverlay(QWidget):
|
||||
|
||||
def _create_top_left_buttons(self):
|
||||
self.camera_btn = self._create_tool_button(
|
||||
icon_path="ui/icons/settings-svgrepo-com.svg",
|
||||
icon_path=str(ICONS_DIR / "settings-svgrepo-com.svg"),
|
||||
callback=self.cameraConnection
|
||||
)
|
||||
|
||||
self.settings_btn = self._create_tool_button(
|
||||
icon_path="ui/icons/error-16-svgrepo-com.svg",
|
||||
icon_path=str(ICONS_DIR / "error-16-svgrepo-com.svg"),
|
||||
callback=self.cameraSettings
|
||||
)
|
||||
|
||||
@@ -228,12 +233,11 @@ class ViewWithOverlay(QWidget):
|
||||
|
||||
def toggle_orientation(self, orientation):
|
||||
if orientation == Qt.Orientation.Vertical:
|
||||
self.flip_btn.setIcon(QIcon("ui/icons/flip-vertical-svgrepo-com.svg"))
|
||||
self.orient_btn.setIcon(QIcon("ui/icons/horizontal-stacks-svgrepo-com.svg"))
|
||||
self.flip_btn.setIcon(QIcon(str(ICONS_DIR / "flip-vertical-svgrepo-com.svg")))
|
||||
self.orient_btn.setIcon(QIcon(str(ICONS_DIR / "horizontal-stacks-svgrepo-com.svg")))
|
||||
else:
|
||||
self.flip_btn.setIcon(QIcon("ui/icons/flip-horizontal-svgrepo-com.svg"))
|
||||
self.orient_btn.setIcon(QIcon("ui/icons/vertical-stacks-svgrepo-com.svg"))
|
||||
|
||||
self.flip_btn.setIcon(QIcon(str(ICONS_DIR / "flip-horizontal-svgrepo-com.svg")))
|
||||
self.orient_btn.setIcon(QIcon(str(ICONS_DIR / "vertical-stacks-svgrepo-com.svg")))
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.live:
|
||||
self.camera_btn.show()
|
||||
@@ -281,10 +285,15 @@ class SplitView(QSplitter):
|
||||
# pixmap.fill(Qt.GlobalColor.lightGray)
|
||||
# self.widget_live.set_image(pixmap)
|
||||
|
||||
self.ref_image_rotate = 0
|
||||
self.original_ref_pixmap = None
|
||||
|
||||
self.widget_live.toggleOrientation.connect(self.toggle_orientation)
|
||||
self.widget_ref.toggleOrientation.connect(self.toggle_orientation)
|
||||
self.widget_live.swapViews.connect(self.swap_views)
|
||||
self.widget_ref.swapViews.connect(self.swap_views)
|
||||
self.widget_ref.rotateCCW.connect(self.rotate_left)
|
||||
self.widget_ref.rotateCW.connect(self.rotate_right)
|
||||
|
||||
def toggle_orientation(self):
|
||||
if self.orientation() == Qt.Orientation.Vertical:
|
||||
@@ -315,8 +324,9 @@ class SplitView(QSplitter):
|
||||
|
||||
def set_reference_image(self, path_image: str):
|
||||
"""Ustawienie obrazu referencyjnego"""
|
||||
pixmap = QPixmap(path_image)
|
||||
self.widget_ref.set_image(pixmap)
|
||||
self.original_ref_pixmap = QPixmap(path_image)
|
||||
self.ref_image_rotate = 0
|
||||
self.widget_ref.set_image(self.original_ref_pixmap)
|
||||
|
||||
def toggle_live_view(self):
|
||||
"""Przełączanie widoku na żywo"""
|
||||
@@ -324,3 +334,19 @@ class SplitView(QSplitter):
|
||||
self.stack.setCurrentWidget(self.widget_live)
|
||||
else:
|
||||
self.stack.setCurrentWidget(self.widget_start)
|
||||
|
||||
def rotate_left(self):
|
||||
if not self.original_ref_pixmap:
|
||||
return
|
||||
self.ref_image_rotate = (self.ref_image_rotate - 90) % 360
|
||||
transform = QTransform().rotate(self.ref_image_rotate)
|
||||
rotated_pixmap = self.original_ref_pixmap.transformed(transform)
|
||||
self.widget_ref.set_image(rotated_pixmap)
|
||||
|
||||
def rotate_right(self):
|
||||
if not self.original_ref_pixmap:
|
||||
return
|
||||
self.ref_image_rotate = (self.ref_image_rotate + 90) % 360
|
||||
transform = QTransform().rotate(self.ref_image_rotate)
|
||||
rotated_pixmap = self.original_ref_pixmap.transformed(transform)
|
||||
self.widget_ref.set_image(rotated_pixmap)
|
||||
Reference in New Issue
Block a user