Initial MVP application skeleton
Add PySide6 camera UI, YOLO/Tesseract detection pipeline, capture metadata, configuration, and project gitignore.
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
.venv-*/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Local/runtime data
|
||||||
|
captures/photos/*
|
||||||
|
captures/videos/*
|
||||||
|
!captures/photos/.gitkeep
|
||||||
|
!captures/videos/.gitkeep
|
||||||
|
models/*
|
||||||
|
!models/.gitkeep
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Ultralytics/runtime caches
|
||||||
|
runs/
|
||||||
|
*.onnx
|
||||||
|
*.engine
|
||||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Duck Stain YOLO
|
||||||
|
|
||||||
|
MVP aplikacji okienkowej do podgladu kamery USB, wykrywania etykiety modelem YOLOv8 i zapisu zdjec/filmow z metadanymi JSON.
|
||||||
|
|
||||||
|
## Uruchomienie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Na macOS z Tesseractem:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install tesseract
|
||||||
|
```
|
||||||
|
|
||||||
|
Na Ubuntu/WSL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install tesseract-ocr
|
||||||
|
```
|
||||||
|
|
||||||
|
Na Linuksie `requirements.txt` wymusza CPU build PyTorch, zeby nie pobierac wariantu CUDA. Na macOS pip zainstaluje standardowy wariant CPU dla procesora Intel.
|
||||||
|
|
||||||
|
Umiesc wytrenowany model jako `models/best.pt` albo zmien `detection.model_path` w `app_config.json`.
|
||||||
|
|
||||||
|
## Konfiguracja
|
||||||
|
|
||||||
|
Glowny plik konfiguracji: `app_config.json`.
|
||||||
|
|
||||||
|
Istotne ustawienia:
|
||||||
|
|
||||||
|
- `camera.width`, `camera.height`, `camera.fps` - rozdzielczosc i FPS kamery.
|
||||||
|
- `camera.properties` - parametry OpenCV ustawiane na kamerze, np. jasnosc, kontrast, ekspozycja. `null` oznacza brak wymuszania wartosci.
|
||||||
|
- `detection.mode` - `best` rysuje najlepsza etykiete, `all` rysuje wszystkie wykrycia.
|
||||||
|
- `detection.frame_stride` - YOLO uruchamiany co N klatek podczas aktywnego wykrywania.
|
||||||
|
- `label_data.models`, `label_data.colors` - slowniki do walidacji tekstu z etykiety.
|
||||||
|
|
||||||
|
Zdjecia trafiaja do `captures/photos`, filmy do `captures/videos`. Obok kazdego pliku media zapisywany jest JSON z aktualnym wynikiem detekcji/OCR.
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
129
app/camera.py
Normal file
129
app/camera.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PySide6.QtCore import QThread, Signal, Slot
|
||||||
|
|
||||||
|
from app.detection import DetectionPipeline, DetectionResult
|
||||||
|
|
||||||
|
|
||||||
|
CV_CAP_PROPS = {
|
||||||
|
"brightness": cv2.CAP_PROP_BRIGHTNESS,
|
||||||
|
"contrast": cv2.CAP_PROP_CONTRAST,
|
||||||
|
"saturation": cv2.CAP_PROP_SATURATION,
|
||||||
|
"hue": cv2.CAP_PROP_HUE,
|
||||||
|
"gain": cv2.CAP_PROP_GAIN,
|
||||||
|
"exposure": cv2.CAP_PROP_EXPOSURE,
|
||||||
|
"sharpness": cv2.CAP_PROP_SHARPNESS,
|
||||||
|
"auto_exposure": cv2.CAP_PROP_AUTO_EXPOSURE,
|
||||||
|
"focus": cv2.CAP_PROP_FOCUS,
|
||||||
|
"auto_focus": cv2.CAP_PROP_AUTOFOCUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def backend_for_name(name: str) -> int:
|
||||||
|
if name == "avfoundation":
|
||||||
|
return cv2.CAP_AVFOUNDATION
|
||||||
|
if name == "v4l2":
|
||||||
|
return cv2.CAP_V4L2
|
||||||
|
if name == "dshow":
|
||||||
|
return cv2.CAP_DSHOW
|
||||||
|
return cv2.CAP_ANY
|
||||||
|
|
||||||
|
|
||||||
|
class CameraWorker(QThread):
|
||||||
|
frame_ready = Signal(object)
|
||||||
|
detection_ready = Signal(object)
|
||||||
|
camera_error = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any], app_config: Any) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.config = config
|
||||||
|
self.app_config = app_config
|
||||||
|
self.pipeline = DetectionPipeline(config, app_config)
|
||||||
|
self._running = threading.Event()
|
||||||
|
self._running.set()
|
||||||
|
self._detecting = False
|
||||||
|
self._accepted = False
|
||||||
|
self._frame_count = 0
|
||||||
|
self._capture: cv2.VideoCapture | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running.clear()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def start_detection(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._detecting = True
|
||||||
|
self._accepted = False
|
||||||
|
self._frame_count = 0
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def accept_detection(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._detecting = False
|
||||||
|
self._accepted = True
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def update_camera_config(self, camera_config: dict[str, Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.config["camera"] = camera_config
|
||||||
|
capture = self._capture
|
||||||
|
if capture is not None:
|
||||||
|
self._apply_camera_settings(capture)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
camera_cfg = self.config["camera"]
|
||||||
|
capture = cv2.VideoCapture(
|
||||||
|
int(camera_cfg.get("index", 0)),
|
||||||
|
backend_for_name(str(camera_cfg.get("backend", "auto"))),
|
||||||
|
)
|
||||||
|
self._capture = capture
|
||||||
|
if not capture.isOpened():
|
||||||
|
self.camera_error.emit("Nie mozna otworzyc kamery USB")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._apply_camera_settings(capture)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running.is_set():
|
||||||
|
ok, frame = capture.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
self.camera_error.emit("Nie mozna odczytac klatki z kamery")
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.frame_ready.emit(frame)
|
||||||
|
self._maybe_detect(frame)
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
self._capture = None
|
||||||
|
|
||||||
|
def _apply_camera_settings(self, capture: cv2.VideoCapture) -> None:
|
||||||
|
camera_cfg = self.config["camera"]
|
||||||
|
capture.set(cv2.CAP_PROP_FRAME_WIDTH, int(camera_cfg.get("width", 1920)))
|
||||||
|
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, int(camera_cfg.get("height", 1080)))
|
||||||
|
capture.set(cv2.CAP_PROP_FPS, int(camera_cfg.get("fps", 30)))
|
||||||
|
|
||||||
|
for name, value in camera_cfg.get("properties", {}).items():
|
||||||
|
if value is None or name not in CV_CAP_PROPS:
|
||||||
|
continue
|
||||||
|
capture.set(CV_CAP_PROPS[name], float(value))
|
||||||
|
|
||||||
|
def _maybe_detect(self, frame: np.ndarray) -> None:
|
||||||
|
with self._lock:
|
||||||
|
detecting = self._detecting and not self._accepted
|
||||||
|
frame_stride = max(1, int(self.config["detection"].get("frame_stride", 5)))
|
||||||
|
self._frame_count += 1
|
||||||
|
should_detect = detecting and self._frame_count % frame_stride == 0
|
||||||
|
|
||||||
|
if not should_detect:
|
||||||
|
return
|
||||||
|
|
||||||
|
result: DetectionResult = self.pipeline.process(frame)
|
||||||
|
self.detection_ready.emit(result)
|
||||||
96
app/config.py
Normal file
96
app/config.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG_PATH = APP_ROOT / "app_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
|
"camera": {
|
||||||
|
"index": 0,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"fps": 30,
|
||||||
|
"backend": "auto",
|
||||||
|
"properties": {
|
||||||
|
"brightness": None,
|
||||||
|
"contrast": None,
|
||||||
|
"saturation": None,
|
||||||
|
"hue": None,
|
||||||
|
"gain": None,
|
||||||
|
"exposure": None,
|
||||||
|
"sharpness": None,
|
||||||
|
"auto_exposure": None,
|
||||||
|
"focus": None,
|
||||||
|
"auto_focus": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"detection": {
|
||||||
|
"model_path": "models/best.pt",
|
||||||
|
"confidence_threshold": 0.25,
|
||||||
|
"mode": "best",
|
||||||
|
"frame_stride": 5,
|
||||||
|
"image_size": 640,
|
||||||
|
"device": "cpu",
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"enabled": True,
|
||||||
|
"language": "eng",
|
||||||
|
"tesseract_cmd": None,
|
||||||
|
"threshold": True,
|
||||||
|
"scale": 2.0,
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"photos_dir": "captures/photos",
|
||||||
|
"videos_dir": "captures/videos",
|
||||||
|
"image_extension": "jpg",
|
||||||
|
"video_extension": "mp4",
|
||||||
|
"video_codec": "mp4v",
|
||||||
|
},
|
||||||
|
"label_data": {"models": ["Regius", "Duvell"], "colors": ["T-NF-BLK-OUT-BST-G", "T-BLK-G"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
result = deepcopy(base)
|
||||||
|
for key, value in override.items():
|
||||||
|
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||||
|
result[key] = deep_merge(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig:
|
||||||
|
def __init__(self, path: Path = CONFIG_PATH) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.data = self.load()
|
||||||
|
|
||||||
|
def load(self) -> dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.save(DEFAULT_CONFIG)
|
||||||
|
return deepcopy(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
with self.path.open("r", encoding="utf-8") as config_file:
|
||||||
|
loaded = json.load(config_file)
|
||||||
|
return deep_merge(DEFAULT_CONFIG, loaded)
|
||||||
|
|
||||||
|
def save(self, data: dict[str, Any] | None = None) -> None:
|
||||||
|
if data is not None:
|
||||||
|
self.data = data
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.path.open("w", encoding="utf-8") as config_file:
|
||||||
|
json.dump(self.data, config_file, indent=2, ensure_ascii=False)
|
||||||
|
config_file.write("\n")
|
||||||
|
|
||||||
|
def resolve_path(self, configured_path: str) -> Path:
|
||||||
|
path = Path(configured_path)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return APP_ROOT / path
|
||||||
183
app/detection.py
Normal file
183
app/detection.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.label_parser import ParsedLabel, parse_label_text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectionResult:
|
||||||
|
xyxy: tuple[int, int, int, int] | None = None
|
||||||
|
confidence: float | None = None
|
||||||
|
class_name: str | None = None
|
||||||
|
raw_text: str = ""
|
||||||
|
parsed: ParsedLabel | None = None
|
||||||
|
error: str | None = None
|
||||||
|
all_boxes: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_metadata(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"bbox_xyxy": list(self.xyxy) if self.xyxy else None,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"class_name": self.class_name,
|
||||||
|
"raw_text": self.raw_text,
|
||||||
|
"parsed": self.parsed.to_dict() if self.parsed else None,
|
||||||
|
"error": self.error,
|
||||||
|
"all_boxes": self.all_boxes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class YoloLabelDetector:
|
||||||
|
def __init__(self, config: dict[str, Any], app_config: Any) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.app_config = app_config
|
||||||
|
self.model = None
|
||||||
|
self.load_error: str | None = None
|
||||||
|
self._load_model()
|
||||||
|
|
||||||
|
def _load_model(self) -> None:
|
||||||
|
model_path = self.app_config.resolve_path(self.config["detection"]["model_path"])
|
||||||
|
if not model_path.exists():
|
||||||
|
self.load_error = f"Brak modelu: {model_path}"
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
self.model = YOLO(str(model_path))
|
||||||
|
except Exception as exc: # pragma: no cover - depends on optional runtime deps
|
||||||
|
self.load_error = f"Nie mozna zaladowac YOLO: {exc}"
|
||||||
|
|
||||||
|
def detect(self, frame_bgr: np.ndarray) -> DetectionResult:
|
||||||
|
if self.model is None:
|
||||||
|
return DetectionResult(error=self.load_error or "Model YOLO nie jest zaladowany")
|
||||||
|
|
||||||
|
detection_cfg = self.config["detection"]
|
||||||
|
try:
|
||||||
|
results = self.model.predict(
|
||||||
|
source=frame_bgr,
|
||||||
|
conf=float(detection_cfg["confidence_threshold"]),
|
||||||
|
imgsz=int(detection_cfg["image_size"]),
|
||||||
|
device=detection_cfg.get("device", "cpu"),
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - depends on model runtime
|
||||||
|
return DetectionResult(error=f"Blad YOLO: {exc}")
|
||||||
|
|
||||||
|
boxes = []
|
||||||
|
names = getattr(self.model, "names", {})
|
||||||
|
for result in results:
|
||||||
|
for box in result.boxes:
|
||||||
|
x1, y1, x2, y2 = [int(v) for v in box.xyxy[0].tolist()]
|
||||||
|
confidence = float(box.conf[0])
|
||||||
|
class_id = int(box.cls[0]) if box.cls is not None else -1
|
||||||
|
class_name = names.get(class_id, str(class_id)) if isinstance(names, dict) else str(class_id)
|
||||||
|
boxes.append(
|
||||||
|
{
|
||||||
|
"xyxy": (x1, y1, x2, y2),
|
||||||
|
"confidence": confidence,
|
||||||
|
"class_name": class_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not boxes:
|
||||||
|
return DetectionResult(error="Nie wykryto etykiety")
|
||||||
|
|
||||||
|
boxes.sort(key=lambda item: item["confidence"], reverse=True)
|
||||||
|
selected = boxes[0]
|
||||||
|
result = DetectionResult(
|
||||||
|
xyxy=selected["xyxy"],
|
||||||
|
confidence=selected["confidence"],
|
||||||
|
class_name=selected["class_name"],
|
||||||
|
all_boxes=[
|
||||||
|
{
|
||||||
|
"xyxy": list(item["xyxy"]),
|
||||||
|
"confidence": item["confidence"],
|
||||||
|
"class_name": item["class_name"],
|
||||||
|
}
|
||||||
|
for item in boxes
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class TesseractOcr:
|
||||||
|
def __init__(self, config: dict[str, Any]) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.load_error: str | None = None
|
||||||
|
self.pytesseract = None
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if not self.config["ocr"].get("enabled", True):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import pytesseract
|
||||||
|
|
||||||
|
command = self.config["ocr"].get("tesseract_cmd")
|
||||||
|
if command:
|
||||||
|
pytesseract.pytesseract.tesseract_cmd = command
|
||||||
|
self.pytesseract = pytesseract
|
||||||
|
except Exception as exc:
|
||||||
|
self.load_error = f"Nie mozna zaladowac pytesseract: {exc}"
|
||||||
|
|
||||||
|
def read_label(self, frame_bgr: np.ndarray, bbox: tuple[int, int, int, int]) -> tuple[str, str | None]:
|
||||||
|
if not self.config["ocr"].get("enabled", True):
|
||||||
|
return "", None
|
||||||
|
if self.pytesseract is None:
|
||||||
|
return "", self.load_error or "OCR nie jest zaladowany"
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = bbox
|
||||||
|
h, w = frame_bgr.shape[:2]
|
||||||
|
x1, y1 = max(0, x1), max(0, y1)
|
||||||
|
x2, y2 = min(w, x2), min(h, y2)
|
||||||
|
if x2 <= x1 or y2 <= y1:
|
||||||
|
return "", "Nieprawidlowy bbox OCR"
|
||||||
|
|
||||||
|
roi = frame_bgr[y1:y2, x1:x2]
|
||||||
|
scale = float(self.config["ocr"].get("scale", 1.0))
|
||||||
|
if scale != 1.0:
|
||||||
|
roi = cv2.resize(roi, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
|
||||||
|
if self.config["ocr"].get("threshold", True):
|
||||||
|
gray = cv2.GaussianBlur(gray, (3, 3), 0)
|
||||||
|
gray = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = self.pytesseract.image_to_string(
|
||||||
|
gray,
|
||||||
|
lang=self.config["ocr"].get("language", "eng"),
|
||||||
|
config="--psm 6",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return "", f"Blad OCR: {exc}"
|
||||||
|
return text, None
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionPipeline:
|
||||||
|
def __init__(self, config: dict[str, Any], app_config: Any) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.detector = YoloLabelDetector(config, app_config)
|
||||||
|
self.ocr = TesseractOcr(config)
|
||||||
|
|
||||||
|
def process(self, frame_bgr: np.ndarray) -> DetectionResult:
|
||||||
|
result = self.detector.detect(frame_bgr)
|
||||||
|
if result.xyxy is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
text, ocr_error = self.ocr.read_label(frame_bgr, result.xyxy)
|
||||||
|
result.raw_text = text
|
||||||
|
result.parsed = parse_label_text(
|
||||||
|
text,
|
||||||
|
self.config["label_data"].get("colors", []),
|
||||||
|
self.config["label_data"].get("models", []),
|
||||||
|
)
|
||||||
|
if ocr_error:
|
||||||
|
result.error = ocr_error
|
||||||
|
return result
|
||||||
44
app/label_parser.py
Normal file
44
app/label_parser.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
|
|
||||||
|
ORDER_RE = re.compile(r"\b(?P<order>\d{4}/\d{4}/(?:[1-9]|[1-9]\d))\b")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedLabel:
|
||||||
|
order_number: str | None
|
||||||
|
color_code: str | None
|
||||||
|
product_model: str | None
|
||||||
|
raw_text: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str | None]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ocr_text(text: str) -> str:
|
||||||
|
return " ".join(text.replace("\n", " ").replace("\r", " ").split())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_label_text(text: str, known_colors: list[str], known_models: list[str]) -> ParsedLabel:
|
||||||
|
normalized = normalize_ocr_text(text)
|
||||||
|
order_match = ORDER_RE.search(normalized)
|
||||||
|
|
||||||
|
normalized_upper = normalized.upper()
|
||||||
|
color_code = next(
|
||||||
|
(color for color in known_colors if color.upper() in normalized_upper),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
product_model = next(
|
||||||
|
(model for model in known_models if re.search(rf"\b{re.escape(model)}\b", normalized, re.I)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ParsedLabel(
|
||||||
|
order_number=order_match.group("order") if order_match else None,
|
||||||
|
color_code=color_code,
|
||||||
|
product_model=product_model,
|
||||||
|
raw_text=normalized,
|
||||||
|
)
|
||||||
11
app/main.py
Normal file
11
app/main.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.config import AppConfig
|
||||||
|
from app.main_window import run_app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app_config = AppConfig()
|
||||||
|
sys.exit(run_app(app_config))
|
||||||
292
app/main_window.py
Normal file
292
app/main_window.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PySide6.QtCore import Qt, Slot
|
||||||
|
from PySide6.QtGui import QAction, QImage, QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QTextEdit,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
QStyle,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.camera import CameraWorker
|
||||||
|
from app.config import AppConfig
|
||||||
|
from app.detection import DetectionResult
|
||||||
|
from app.media import MediaStore, VideoRecorder
|
||||||
|
from app.settings_dialog import SettingsDialog
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self, app_config: AppConfig) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.app_config = app_config
|
||||||
|
self.config = app_config.data
|
||||||
|
self.last_frame: np.ndarray | None = None
|
||||||
|
self.overlay_result: DetectionResult | None = None
|
||||||
|
self.last_detection: DetectionResult | None = None
|
||||||
|
self.media_store = MediaStore(self.config, self.app_config)
|
||||||
|
self.video_recorder = VideoRecorder(self.config, self.app_config)
|
||||||
|
|
||||||
|
self.setWindowTitle("Duck Stain YOLO")
|
||||||
|
self.resize(1280, 720)
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
self.worker = CameraWorker(self.config, self.app_config)
|
||||||
|
self.worker.frame_ready.connect(self.on_frame_ready)
|
||||||
|
self.worker.detection_ready.connect(self.on_detection_ready)
|
||||||
|
self.worker.camera_error.connect(self.on_camera_error)
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
self.stage = QWidget()
|
||||||
|
self.setCentralWidget(self.stage)
|
||||||
|
|
||||||
|
self.video_label = QLabel(self.stage)
|
||||||
|
self.video_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.video_label.setStyleSheet("background: #111; color: #ddd;")
|
||||||
|
self.video_label.setText("Kamera")
|
||||||
|
|
||||||
|
self.result_panel = QWidget(self.stage)
|
||||||
|
self.result_panel.setObjectName("resultPanel")
|
||||||
|
self.result_panel.setStyleSheet(
|
||||||
|
"""
|
||||||
|
QWidget#resultPanel {
|
||||||
|
background: rgba(20, 20, 20, 170);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QTextEdit {
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
panel_layout = QVBoxLayout(self.result_panel)
|
||||||
|
self.result_text = QTextEdit()
|
||||||
|
self.result_text.setReadOnly(True)
|
||||||
|
self.result_text.setFixedHeight(118)
|
||||||
|
panel_layout.addWidget(self.result_text)
|
||||||
|
panel_buttons = QHBoxLayout()
|
||||||
|
self.detect_button = QPushButton("wykryj")
|
||||||
|
self.ok_button = QPushButton("ok")
|
||||||
|
panel_buttons.addStretch(1)
|
||||||
|
panel_buttons.addWidget(self.detect_button)
|
||||||
|
panel_buttons.addWidget(self.ok_button)
|
||||||
|
panel_layout.addLayout(panel_buttons)
|
||||||
|
self.detect_button.clicked.connect(self.start_detection)
|
||||||
|
self.ok_button.clicked.connect(self.accept_detection)
|
||||||
|
|
||||||
|
self.toolbar = QWidget(self.stage)
|
||||||
|
self.toolbar.setObjectName("bottomToolbar")
|
||||||
|
self.toolbar.setStyleSheet(
|
||||||
|
"""
|
||||||
|
QWidget#bottomToolbar {
|
||||||
|
background: rgba(20, 20, 20, 175);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QToolButton {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
toolbar_layout = QHBoxLayout(self.toolbar)
|
||||||
|
toolbar_layout.setContentsMargins(8, 6, 8, 6)
|
||||||
|
self.photo_button = self._tool_button(QStyle.SP_DialogSaveButton, "Zrob zdjecie")
|
||||||
|
self.record_button = self._tool_button(QStyle.SP_MediaPlay, "Start/stop nagrywania")
|
||||||
|
self.settings_button = self._tool_button(QStyle.SP_FileDialogDetailedView, "Ustawienia obrazu")
|
||||||
|
toolbar_layout.addWidget(self.photo_button)
|
||||||
|
toolbar_layout.addWidget(self.record_button)
|
||||||
|
toolbar_layout.addWidget(self.settings_button)
|
||||||
|
self.photo_button.clicked.connect(self.take_photo)
|
||||||
|
self.record_button.clicked.connect(self.toggle_recording)
|
||||||
|
self.settings_button.clicked.connect(self.open_settings)
|
||||||
|
|
||||||
|
quit_action = QAction("Zamknij", self)
|
||||||
|
quit_action.triggered.connect(self.close)
|
||||||
|
self.addAction(quit_action)
|
||||||
|
|
||||||
|
def _tool_button(self, icon_id: QStyle.StandardPixmap, tooltip: str) -> QToolButton:
|
||||||
|
button = QToolButton()
|
||||||
|
button.setIcon(self.style().standardIcon(icon_id))
|
||||||
|
button.setToolTip(tooltip)
|
||||||
|
return button
|
||||||
|
|
||||||
|
def resizeEvent(self, event: Any) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self.video_label.setGeometry(self.stage.rect())
|
||||||
|
|
||||||
|
panel_width = min(420, max(280, self.stage.width() // 3))
|
||||||
|
self.result_panel.setGeometry(self.stage.width() - panel_width - 18, 18, panel_width, 190)
|
||||||
|
|
||||||
|
self.toolbar.adjustSize()
|
||||||
|
toolbar_size = self.toolbar.sizeHint()
|
||||||
|
self.toolbar.setGeometry(
|
||||||
|
(self.stage.width() - toolbar_size.width()) // 2,
|
||||||
|
self.stage.height() - toolbar_size.height() - 18,
|
||||||
|
toolbar_size.width(),
|
||||||
|
toolbar_size.height(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def closeEvent(self, event: Any) -> None:
|
||||||
|
if self.video_recorder.is_recording:
|
||||||
|
self.video_recorder.stop(self.current_metadata("video"))
|
||||||
|
self.worker.stop()
|
||||||
|
self.worker.wait(2000)
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
@Slot(object)
|
||||||
|
def on_frame_ready(self, frame: np.ndarray) -> None:
|
||||||
|
self.last_frame = frame.copy()
|
||||||
|
if self.video_recorder.is_recording:
|
||||||
|
self.video_recorder.write(frame)
|
||||||
|
self._show_frame(frame)
|
||||||
|
|
||||||
|
@Slot(object)
|
||||||
|
def on_detection_ready(self, result: DetectionResult) -> None:
|
||||||
|
self.last_detection = result
|
||||||
|
self.overlay_result = result if result.xyxy else None
|
||||||
|
self._update_result_text(result)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def on_camera_error(self, message: str) -> None:
|
||||||
|
self.result_text.setPlainText(message)
|
||||||
|
|
||||||
|
def start_detection(self) -> None:
|
||||||
|
self.overlay_result = None
|
||||||
|
self.result_text.setPlainText("Wykrywanie...")
|
||||||
|
self.worker.start_detection()
|
||||||
|
|
||||||
|
def accept_detection(self) -> None:
|
||||||
|
self.worker.accept_detection()
|
||||||
|
self.overlay_result = None
|
||||||
|
if self.last_detection:
|
||||||
|
self._update_result_text(self.last_detection, accepted=True)
|
||||||
|
|
||||||
|
def take_photo(self) -> None:
|
||||||
|
if self.last_frame is None:
|
||||||
|
QMessageBox.warning(self, "Zdjecie", "Brak klatki z kamery")
|
||||||
|
return
|
||||||
|
path = self.media_store.save_photo(self.last_frame, self.current_metadata("photo"))
|
||||||
|
self.statusBar().showMessage(f"Zapisano zdjecie: {path}", 5000)
|
||||||
|
|
||||||
|
def toggle_recording(self) -> None:
|
||||||
|
if self.last_frame is None:
|
||||||
|
QMessageBox.warning(self, "Wideo", "Brak klatki z kamery")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.video_recorder.is_recording:
|
||||||
|
path = self.video_recorder.stop(self.current_metadata("video"))
|
||||||
|
self.record_button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
|
||||||
|
self.statusBar().showMessage(f"Zapisano film: {path}", 5000)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = self.video_recorder.start(self.last_frame)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
QMessageBox.warning(self, "Wideo", str(exc))
|
||||||
|
return
|
||||||
|
self.record_button.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
|
||||||
|
self.statusBar().showMessage(f"Nagrywanie: {path}", 5000)
|
||||||
|
|
||||||
|
def open_settings(self) -> None:
|
||||||
|
dialog = SettingsDialog(self.config, self)
|
||||||
|
dialog.settings_saved.connect(self.save_camera_settings)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def save_camera_settings(self, camera_config: dict[str, Any]) -> None:
|
||||||
|
self.config["camera"] = camera_config
|
||||||
|
self.app_config.save(self.config)
|
||||||
|
self.worker.update_camera_config(camera_config)
|
||||||
|
|
||||||
|
def current_metadata(self, media_type: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"media_type": media_type,
|
||||||
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"detection": self.last_detection.to_metadata() if self.last_detection else None,
|
||||||
|
"camera": {
|
||||||
|
"width": self.config["camera"].get("width"),
|
||||||
|
"height": self.config["camera"].get("height"),
|
||||||
|
"fps": self.config["camera"].get("fps"),
|
||||||
|
"properties": self.config["camera"].get("properties", {}),
|
||||||
|
},
|
||||||
|
"detection_config": self.config.get("detection", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_result_text(self, result: DetectionResult, accepted: bool = False) -> None:
|
||||||
|
status = "Zatwierdzono" if accepted else "Wynik"
|
||||||
|
lines = [status]
|
||||||
|
if result.error:
|
||||||
|
lines.append(f"Komunikat: {result.error}")
|
||||||
|
if result.confidence is not None:
|
||||||
|
lines.append(f"YOLO confidence: {result.confidence:.3f}")
|
||||||
|
if result.parsed:
|
||||||
|
lines.append(f"Zamowienie: {result.parsed.order_number or '-'}")
|
||||||
|
lines.append(f"Kolor: {result.parsed.color_code or '-'}")
|
||||||
|
lines.append(f"Model: {result.parsed.product_model or '-'}")
|
||||||
|
if result.raw_text:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(result.raw_text)
|
||||||
|
self.result_text.setPlainText("\n".join(lines))
|
||||||
|
|
||||||
|
def _show_frame(self, frame_bgr: np.ndarray) -> None:
|
||||||
|
display_frame = frame_bgr.copy()
|
||||||
|
if self.overlay_result is not None:
|
||||||
|
self._draw_detection(display_frame, self.overlay_result)
|
||||||
|
|
||||||
|
frame_rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB)
|
||||||
|
h, w, channels = frame_rgb.shape
|
||||||
|
image = QImage(frame_rgb.data, w, h, channels * w, QImage.Format_RGB888).copy()
|
||||||
|
pixmap = QPixmap.fromImage(image)
|
||||||
|
self.video_label.setPixmap(
|
||||||
|
pixmap.scaled(self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_detection(self, frame_bgr: np.ndarray, result: DetectionResult) -> None:
|
||||||
|
mode = self.config["detection"].get("mode", "best")
|
||||||
|
boxes = result.all_boxes if mode == "all" else [result.to_metadata()]
|
||||||
|
for item in boxes:
|
||||||
|
xyxy = item.get("xyxy") or item.get("bbox_xyxy")
|
||||||
|
if not xyxy:
|
||||||
|
continue
|
||||||
|
x1, y1, x2, y2 = [int(value) for value in xyxy]
|
||||||
|
confidence = item.get("confidence")
|
||||||
|
class_name = item.get("class_name") or "label"
|
||||||
|
cv2.rectangle(frame_bgr, (x1, y1), (x2, y2), (0, 220, 0), 3)
|
||||||
|
caption = f"{class_name} {confidence:.2f}" if confidence is not None else class_name
|
||||||
|
cv2.putText(
|
||||||
|
frame_bgr,
|
||||||
|
caption,
|
||||||
|
(x1, max(24, y1 - 8)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.8,
|
||||||
|
(0, 220, 0),
|
||||||
|
2,
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_app(app_config: AppConfig) -> int:
|
||||||
|
app = QApplication([])
|
||||||
|
window = MainWindow(app_config)
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
101
app/media.py
Normal file
101
app/media.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp_name() -> str:
|
||||||
|
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
def write_metadata(media_path: Path, metadata: dict[str, Any]) -> Path:
|
||||||
|
json_path = media_path.with_suffix(".json")
|
||||||
|
with json_path.open("w", encoding="utf-8") as metadata_file:
|
||||||
|
json.dump(metadata, metadata_file, indent=2, ensure_ascii=False)
|
||||||
|
metadata_file.write("\n")
|
||||||
|
return json_path
|
||||||
|
|
||||||
|
|
||||||
|
class MediaStore:
|
||||||
|
def __init__(self, config: dict[str, Any], app_config: Any) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.app_config = app_config
|
||||||
|
|
||||||
|
def photo_path(self) -> Path:
|
||||||
|
capture_cfg = self.config["capture"]
|
||||||
|
directory = self.app_config.resolve_path(capture_cfg["photos_dir"])
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
extension = capture_cfg.get("image_extension", "jpg").lstrip(".")
|
||||||
|
return directory / f"{timestamp_name()}.{extension}"
|
||||||
|
|
||||||
|
def video_path(self) -> Path:
|
||||||
|
capture_cfg = self.config["capture"]
|
||||||
|
directory = self.app_config.resolve_path(capture_cfg["videos_dir"])
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
extension = capture_cfg.get("video_extension", "mp4").lstrip(".")
|
||||||
|
return directory / f"{timestamp_name()}.{extension}"
|
||||||
|
|
||||||
|
def save_photo(self, frame_bgr: np.ndarray, metadata: dict[str, Any]) -> Path:
|
||||||
|
path = self.photo_path()
|
||||||
|
cv2.imwrite(str(path), frame_bgr)
|
||||||
|
write_metadata(path, metadata)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class VideoRecorder:
|
||||||
|
def __init__(self, config: dict[str, Any], app_config: Any) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.app_config = app_config
|
||||||
|
self.path: Path | None = None
|
||||||
|
self.writer: cv2.VideoWriter | None = None
|
||||||
|
self.started_at: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_recording(self) -> bool:
|
||||||
|
return self.writer is not None
|
||||||
|
|
||||||
|
def start(self, frame_bgr: np.ndarray) -> Path:
|
||||||
|
if self.writer is not None:
|
||||||
|
raise RuntimeError("Nagrywanie juz trwa")
|
||||||
|
|
||||||
|
capture_cfg = self.config["capture"]
|
||||||
|
self.path = MediaStore(self.config, self.app_config).video_path()
|
||||||
|
h, w = frame_bgr.shape[:2]
|
||||||
|
fps = float(self.config["camera"].get("fps", 30))
|
||||||
|
codec = str(capture_cfg.get("video_codec", "mp4v"))
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*codec[:4])
|
||||||
|
self.writer = cv2.VideoWriter(str(self.path), fourcc, fps, (w, h))
|
||||||
|
if not self.writer.isOpened():
|
||||||
|
self.writer = None
|
||||||
|
raise RuntimeError("Nie mozna uruchomic zapisu wideo")
|
||||||
|
self.started_at = datetime.now().isoformat(timespec="seconds")
|
||||||
|
self.write(frame_bgr)
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
def write(self, frame_bgr: np.ndarray) -> None:
|
||||||
|
if self.writer is not None:
|
||||||
|
self.writer.write(frame_bgr)
|
||||||
|
|
||||||
|
def stop(self, metadata: dict[str, Any]) -> Path | None:
|
||||||
|
if self.writer is None:
|
||||||
|
return None
|
||||||
|
self.writer.release()
|
||||||
|
self.writer = None
|
||||||
|
path = self.path
|
||||||
|
if path is not None:
|
||||||
|
metadata = {
|
||||||
|
**metadata,
|
||||||
|
"recording": {
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"stopped_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
write_metadata(path, metadata)
|
||||||
|
self.path = None
|
||||||
|
self.started_at = None
|
||||||
|
return path
|
||||||
125
app/settings_dialog.py
Normal file
125
app/settings_dialog.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSlider,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAMERA_PROPERTY_LABELS = {
|
||||||
|
"brightness": "Jasnosc",
|
||||||
|
"contrast": "Kontrast",
|
||||||
|
"saturation": "Nasycenie",
|
||||||
|
"hue": "Barwa",
|
||||||
|
"gain": "Gain",
|
||||||
|
"exposure": "Ekspozycja",
|
||||||
|
"sharpness": "Ostrosc",
|
||||||
|
"auto_exposure": "Auto ekspozycja",
|
||||||
|
"focus": "Focus",
|
||||||
|
"auto_focus": "Auto focus",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PropertySlider(QWidget):
|
||||||
|
value_changed = Signal(str, object)
|
||||||
|
|
||||||
|
def __init__(self, name: str, value: float | None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.name = name
|
||||||
|
self.enabled_box = QCheckBox()
|
||||||
|
self.enabled_box.setChecked(value is not None)
|
||||||
|
self.slider = QSlider(Qt.Horizontal)
|
||||||
|
self.slider.setRange(-100, 100)
|
||||||
|
self.slider.setValue(int(value) if value is not None else 0)
|
||||||
|
self.value_box = QSpinBox()
|
||||||
|
self.value_box.setRange(-100, 100)
|
||||||
|
self.value_box.setValue(self.slider.value())
|
||||||
|
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.addWidget(self.enabled_box)
|
||||||
|
layout.addWidget(self.slider, 1)
|
||||||
|
layout.addWidget(self.value_box)
|
||||||
|
|
||||||
|
self.slider.valueChanged.connect(self.value_box.setValue)
|
||||||
|
self.value_box.valueChanged.connect(self.slider.setValue)
|
||||||
|
self.slider.valueChanged.connect(self._emit_value)
|
||||||
|
self.enabled_box.toggled.connect(self._emit_value)
|
||||||
|
|
||||||
|
def _emit_value(self) -> None:
|
||||||
|
self.value_changed.emit(self.name, self.value())
|
||||||
|
|
||||||
|
def value(self) -> float | None:
|
||||||
|
if not self.enabled_box.isChecked():
|
||||||
|
return None
|
||||||
|
return float(self.slider.value())
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog(QDialog):
|
||||||
|
settings_saved = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any], parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Ustawienia obrazu")
|
||||||
|
self.setMinimumWidth(520)
|
||||||
|
self.config = deepcopy(config)
|
||||||
|
self.property_widgets: dict[str, PropertySlider] = {}
|
||||||
|
|
||||||
|
camera_cfg = self.config["camera"]
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.width_box = QSpinBox()
|
||||||
|
self.width_box.setRange(160, 7680)
|
||||||
|
self.width_box.setValue(int(camera_cfg.get("width", 1920)))
|
||||||
|
self.height_box = QSpinBox()
|
||||||
|
self.height_box.setRange(120, 4320)
|
||||||
|
self.height_box.setValue(int(camera_cfg.get("height", 1080)))
|
||||||
|
self.fps_box = QSpinBox()
|
||||||
|
self.fps_box.setRange(1, 240)
|
||||||
|
self.fps_box.setValue(int(camera_cfg.get("fps", 30)))
|
||||||
|
|
||||||
|
form.addRow("Szerokosc", self.width_box)
|
||||||
|
form.addRow("Wysokosc", self.height_box)
|
||||||
|
form.addRow("FPS", self.fps_box)
|
||||||
|
|
||||||
|
for name, label in CAMERA_PROPERTY_LABELS.items():
|
||||||
|
widget = PropertySlider(name, camera_cfg.get("properties", {}).get(name))
|
||||||
|
self.property_widgets[name] = widget
|
||||||
|
form.addRow(QLabel(label), widget)
|
||||||
|
|
||||||
|
main_layout.addLayout(form)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.addStretch(1)
|
||||||
|
cancel_button = QPushButton("Anuluj")
|
||||||
|
save_button = QPushButton("Zapisz")
|
||||||
|
save_button.setDefault(True)
|
||||||
|
buttons.addWidget(cancel_button)
|
||||||
|
buttons.addWidget(save_button)
|
||||||
|
main_layout.addLayout(buttons)
|
||||||
|
|
||||||
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
save_button.clicked.connect(self._save)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.config["camera"]["width"] = int(self.width_box.value())
|
||||||
|
self.config["camera"]["height"] = int(self.height_box.value())
|
||||||
|
self.config["camera"]["fps"] = int(self.fps_box.value())
|
||||||
|
self.config["camera"]["properties"] = {
|
||||||
|
name: widget.value() for name, widget in self.property_widgets.items()
|
||||||
|
}
|
||||||
|
self.settings_saved.emit(self.config["camera"])
|
||||||
|
self.accept()
|
||||||
53
app_config.json
Normal file
53
app_config.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"camera": {
|
||||||
|
"index": 0,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"fps": 30,
|
||||||
|
"backend": "auto",
|
||||||
|
"properties": {
|
||||||
|
"brightness": null,
|
||||||
|
"contrast": null,
|
||||||
|
"saturation": null,
|
||||||
|
"hue": null,
|
||||||
|
"gain": null,
|
||||||
|
"exposure": null,
|
||||||
|
"sharpness": null,
|
||||||
|
"auto_exposure": null,
|
||||||
|
"focus": null,
|
||||||
|
"auto_focus": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"detection": {
|
||||||
|
"model_path": "models/best.pt",
|
||||||
|
"confidence_threshold": 0.25,
|
||||||
|
"mode": "best",
|
||||||
|
"frame_stride": 5,
|
||||||
|
"image_size": 640,
|
||||||
|
"device": "cpu"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"enabled": true,
|
||||||
|
"language": "eng",
|
||||||
|
"tesseract_cmd": null,
|
||||||
|
"threshold": true,
|
||||||
|
"scale": 2.0
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"photos_dir": "captures/photos",
|
||||||
|
"videos_dir": "captures/videos",
|
||||||
|
"image_extension": "jpg",
|
||||||
|
"video_extension": "mp4",
|
||||||
|
"video_codec": "mp4v"
|
||||||
|
},
|
||||||
|
"label_data": {
|
||||||
|
"models": [
|
||||||
|
"Regius",
|
||||||
|
"Duvell"
|
||||||
|
],
|
||||||
|
"colors": [
|
||||||
|
"T-NF-BLK-OUT-BST-G",
|
||||||
|
"T-BLK-G"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
captures/photos/.gitkeep
Normal file
1
captures/photos/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
captures/videos/.gitkeep
Normal file
1
captures/videos/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
main.py
Normal file
5
main.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app.main import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
models/.gitkeep
Normal file
1
models/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
15
pyproject.toml
Normal file
15
pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[project]
|
||||||
|
name = "duck-stain-yolo"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "PySide6 camera app for YOLO label detection and OCR metadata capture."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"PySide6>=6.6",
|
||||||
|
"opencv-python>=4.8",
|
||||||
|
"ultralytics>=8.0",
|
||||||
|
"pytesseract>=0.3",
|
||||||
|
"numpy>=1.26",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
PySide6>=6.6
|
||||||
|
opencv-python>=4.8
|
||||||
|
torch==2.5.1+cpu; platform_system == "Linux"
|
||||||
|
torchvision==0.20.1+cpu; platform_system == "Linux"
|
||||||
|
ultralytics>=8.0
|
||||||
|
pytesseract>=0.3
|
||||||
|
numpy>=1.26
|
||||||
Reference in New Issue
Block a user