from __future__ import annotations import json import os import shutil from pathlib import Path from typing import Dict, List, Optional, Tuple import numpy as np from PIL import Image, ImageQt from PySide6.QtCore import QObject, QPointF, QRectF, QSize, Qt, QThread, QTimer, Signal from PySide6.QtGui import ( QBrush, QColor, QDragEnterEvent, QDropEvent, QPainter, QPainterPath, QPen, QPixmap, ) from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QColorDialog, QComboBox, QDoubleSpinBox, QFileDialog, QGraphicsItem, QGraphicsPixmapItem, QGraphicsRectItem, QGraphicsScene, QGraphicsView, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QRadioButton, QScrollArea, QSlider, QSpinBox, QSplitter, QVBoxLayout, QWidget, ) from fotoapp.image_ops import ( Adjustments, HUE_BAND_NAMES, apply_adjustments, apply_post_adj, cm_to_px, crop_center_aspect, crop_circle, crop_pil, ensure_srgb, pil_to_rgb_float01, resize_to_output, rgb_float01_to_pil, ) from fotoapp.lut import CubeParseError, get_cached_lut from fotoapp.segmentation import ( MaskHistory, apply_brush_stroke, composite_fg_bg, feather_mask, segment_person, ) from fotoapp.retouch import soften_skin IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"} LUT_DIR = Path(__file__).parent / "luts" _APPDATA = os.environ.get("APPDATA", "") CONFIG_DIR = Path(_APPDATA) / "FotoApp" if _APPDATA else Path.home() / ".fotoapp" CONFIG_PATH = CONFIG_DIR / "config.json" _HUE_LABELS: dict[str, str] = { "red": "Rot", "orange": "Orange", "yellow": "Gelb", "green": "Grün", "cyan": "Cyan", "blue": "Blau", "magenta": "Magenta", } DARK_STYLE = """ QMainWindow { background: #0a0a0a; } QWidget { background: #0a0a0a; color: #ddd; font-size: 13px; } QPushButton { background: #1a1a1a; border: 1px solid #333; border-radius: 6px; padding: 8px 14px; color: #eee; } QPushButton:hover { background: #2a2a2a; } QPushButton:disabled { background: #111; color: #555; } QGroupBox { background: #0e0e0e; border: 1px solid #2a2a2a; border-radius: 8px; margin-top: 14px; padding-top: 20px; font-weight: bold; } QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 6px; color: #bbb; } QListWidget { background: #111; border: 1px solid #2a2a2a; border-radius: 6px; color: #ddd; } QListWidget::item:selected { background: #1a3040; color: #fff; } QSlider::groove:horizontal { height: 6px; background: #333; border-radius: 3px; } QSlider::handle:horizontal { width: 16px; height: 16px; background: #00c8ff; border-radius: 8px; margin: -5px 0; } QComboBox, QSpinBox, QDoubleSpinBox, QLineEdit { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 4px 8px; color: #ddd; } QComboBox QAbstractItemView { background: #1a1a1a; color: #ddd; selection-background-color: #1a3040; } QCheckBox { spacing: 6px; } QCheckBox::indicator { width: 16px; height: 16px; } QScrollArea { background: #0a0a0a; border: none; } QSplitter::handle { background: #1a1a1a; } QProgressBar { background: #111; border: 1px solid #333; border-radius: 4px; text-align: center; height: 20px; color: #ddd; } QProgressBar::chunk { background: #00c8ff; border-radius: 3px; } QGraphicsView { background: #111; border: 1px solid #333; border-radius: 6px; } """ def _is_image_file(p: Path) -> bool: return p.suffix.lower() in IMAGE_EXTENSIONS # ─── Drop area ─────────────────────────────────────────────────────────────── class DropArea(QLabel): files_dropped = Signal(list) _STYLE_NORMAL = ( "QLabel { border: 3px dashed #888; border-radius: 16px;" " background: #2a2a2a; color: #aaa; font-size: 15px; padding: 18px; }" ) _STYLE_HOVER = ( "QLabel { border: 3px dashed #00c8ff; border-radius: 16px;" " background: #1a3040; color: #00c8ff; font-size: 15px; padding: 18px; }" ) def __init__(self): super().__init__() self.setAcceptDrops(True) self.setAlignment(Qt.AlignCenter) self.setMinimumHeight(80) self.setStyleSheet(self._STYLE_NORMAL) self.setText("Fotos hierher ziehen\n(Dateien oder Ordner)") def dragEnterEvent(self, ev: QDragEnterEvent): if ev.mimeData().hasUrls(): ev.acceptProposedAction() self.setStyleSheet(self._STYLE_HOVER) def dragLeaveEvent(self, ev): self.setStyleSheet(self._STYLE_NORMAL) def dropEvent(self, ev: QDropEvent): self.setStyleSheet(self._STYLE_NORMAL) paths: list[Path] = [] for url in ev.mimeData().urls(): p = Path(url.toLocalFile()) if p.is_file() and _is_image_file(p): paths.append(p) elif p.is_dir(): for f in sorted(p.iterdir()): if f.is_file() and _is_image_file(f): paths.append(f) if paths: self.files_dropped.emit(paths) # ─── Labeled slider ───────────────────────────────────────────────────────── class LabeledSlider(QWidget): valueChanged = Signal(int) def __init__(self, label: str, lo: int, hi: int, default: int, suffix: str = ""): super().__init__() lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) self._lbl_name = QLabel(label) self._lbl_name.setMinimumWidth(110) lay.addWidget(self._lbl_name) self.slider = QSlider(Qt.Horizontal) self.slider.setRange(lo, hi) self.slider.setValue(default) self.slider.valueChanged.connect(self._on_changed) lay.addWidget(self.slider, 1) self._suffix = suffix self._lbl_val = QLabel(f"{default}{suffix}") self._lbl_val.setMinimumWidth(50) self._lbl_val.setAlignment(Qt.AlignRight | Qt.AlignVCenter) lay.addWidget(self._lbl_val) self._default = default def _on_changed(self, val: int): self._lbl_val.setText(f"{val}{self._suffix}") self.valueChanged.emit(val) def value(self) -> int: return self.slider.value() def reset(self): self.slider.setValue(self._default) # ─── Interactive crop overlay ──────────────────────────────────────────────── class CropOverlay(QGraphicsRectItem): def __init__(self, rect: QRectF, scene_bounds: QRectF): super().__init__(rect) self._scene_bounds = scene_bounds self.setFlags( QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemSendsGeometryChanges ) self.setAcceptHoverEvents(True) self._handle_size = 12.0 self._active_handle: Optional[str] = None self._drag_start: Optional[QPointF] = None self._start_rect: Optional[QRectF] = None self._aspect: Optional[float] = None self._is_circle = False self._min_size = 20.0 pen = QPen(QColor(0, 200, 255), 2) pen.setCosmetic(True) self.setPen(pen) self.setBrush(QBrush(QColor(0, 200, 255, 30))) def set_aspect(self, aspect: Optional[float]): self._aspect = aspect if aspect and self.rect().height() > 0: r = self.rect() new_w = r.height() * aspect r.setWidth(new_w) self.setRect(r) def set_circle(self, is_circle: bool): self._is_circle = is_circle if is_circle: self._aspect = 1.0 r = self.rect() side = min(r.width(), r.height()) r.setWidth(side) r.setHeight(side) self.setRect(r) self.update() def _handles(self) -> dict: r = self.rect() s = self._handle_size hs = s / 2 return { "tl": QRectF(r.left() - hs, r.top() - hs, s, s), "tr": QRectF(r.right() - hs, r.top() - hs, s, s), "bl": QRectF(r.left() - hs, r.bottom() - hs, s, s), "br": QRectF(r.right() - hs, r.bottom() - hs, s, s), } def boundingRect(self): hs = self._handle_size base = self.rect().adjusted(-hs, -hs, hs, hs) scene_local = QRectF(self._scene_bounds).translated( -self.pos().x(), -self.pos().y() ) return base.united(scene_local) def hoverMoveEvent(self, event): for hr in self._handles().values(): if hr.contains(event.pos()): self.setCursor(Qt.SizeFDiagCursor) return self.setCursor(Qt.SizeAllCursor) def mousePressEvent(self, event): self._active_handle = None for name, hr in self._handles().items(): if hr.contains(event.pos()): self._active_handle = name break self._drag_start = event.scenePos() self._start_rect = QRectF(self.rect()) if not self._active_handle: super().mousePressEvent(event) def mouseMoveEvent(self, event): if self._active_handle and self._drag_start and self._start_rect: delta = event.scenePos() - self._drag_start r = QRectF(self._start_rect) asp = self._aspect if self._active_handle == "br": new_w = max(self._min_size, r.width() + delta.x()) new_h = (r.height() + delta.y()) if not asp else new_w / asp new_h = max(self._min_size, new_h) r.setWidth(new_w) r.setHeight(new_h) elif self._active_handle == "bl": new_w = max(self._min_size, r.width() - delta.x()) new_h = (r.height() + delta.y()) if not asp else new_w / asp new_h = max(self._min_size, new_h) r.setLeft(r.right() - new_w) r.setHeight(new_h) elif self._active_handle == "tr": new_w = max(self._min_size, r.width() + delta.x()) new_h = (r.height() - delta.y()) if not asp else new_w / asp new_h = max(self._min_size, new_h) r.setTop(r.bottom() - new_h) r.setWidth(new_w) elif self._active_handle == "tl": new_w = max(self._min_size, r.width() - delta.x()) new_h = (r.height() - delta.y()) if not asp else new_w / asp new_h = max(self._min_size, new_h) r.setLeft(r.right() - new_w) r.setTop(r.bottom() - new_h) self.prepareGeometryChange() self.setRect(r) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) self._active_handle = None def paint(self, painter: QPainter, option, widget=None): r = self.rect() # Dimming overlay outside crop area scene_local = QRectF(self._scene_bounds).translated( -self.pos().x(), -self.pos().y() ) outer = QPainterPath() outer.addRect(scene_local) inner = QPainterPath() if self._is_circle: inner.addEllipse(r) else: inner.addRect(r) painter.setPen(Qt.NoPen) painter.setBrush(QBrush(QColor(0, 0, 0, 100))) painter.drawPath(outer.subtracted(inner)) # Border painter.setPen(self.pen()) painter.setBrush(Qt.NoBrush) if self._is_circle: painter.drawEllipse(r) else: painter.drawRect(r) # Handles painter.setPen(QPen(QColor(255, 255, 255), 1)) painter.setBrush(QBrush(QColor(255, 255, 255))) for hr in self._handles().values(): if self._is_circle: painter.drawEllipse(hr) else: painter.drawRect(hr) # ─── Crop preview (QGraphicsView) ─────────────────────────────────────────── class CropPreviewView(QGraphicsView): crop_changed = Signal() brush_painted = Signal(list) # list of (x, y) in original coords def __init__(self): super().__init__() self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) self.setDragMode(QGraphicsView.NoDrag) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setFocusPolicy(Qt.StrongFocus) self._scene = QGraphicsScene(self) self.setScene(self._scene) self._pix_item: Optional[QGraphicsPixmapItem] = None self._crop_item: Optional[CropOverlay] = None self._img_w = 0 self._img_h = 0 self._pending_aspect: Optional[float] = None self._pending_circle: bool = False self._draw_start: Optional[QPointF] = None self._brush_active: bool = False self._brush_points: list = [] def set_image(self, pix: QPixmap, orig_w: int, orig_h: int): self._scene.clear() self._pix_item = self._scene.addPixmap(pix) self._img_w = orig_w self._img_h = orig_h self._crop_item = None self._draw_start = None self._scene.setSceneRect(QRectF(0, 0, pix.width(), pix.height())) self._fit_image() def _fit_image(self): if self._pix_item: self.fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio) def showEvent(self, event): super().showEvent(event) self._fit_image() def show_crop_overlay(self): if self._pix_item is None: return pix = self._pix_item.pixmap() if self._crop_item is None: cw = pix.width() * 0.8 ch = pix.height() * 0.8 crop_rect = QRectF( (pix.width() - cw) / 2, (pix.height() - ch) / 2, cw, ch ) self._crop_item = CropOverlay(crop_rect, self._scene.sceneRect()) self._scene.addItem(self._crop_item) if self._pending_aspect is not None: self._crop_item.set_aspect(self._pending_aspect) if self._pending_circle: self._crop_item.set_circle(True) else: self._crop_item.setVisible(True) def hide_crop_overlay(self): if self._crop_item is not None: self._crop_item.setVisible(False) def is_crop_visible(self) -> bool: return self._crop_item is not None and self._crop_item.isVisible() # ── Other helpers ──────────────────────────────────────────────────── def update_pixmap_only(self, pix: QPixmap): if self._pix_item: self._pix_item.setPixmap(pix) def clear_view(self): self._scene.clear() self._pix_item = None self._crop_item = None def set_crop_aspect(self, aspect: Optional[float]): self._pending_aspect = aspect if self._crop_item: self._crop_item.set_aspect(aspect) def set_circle_mode(self, is_circle: bool): self._pending_circle = is_circle if self._crop_item: self._crop_item.set_circle(is_circle) def get_crop_xyxy_original(self) -> Optional[Tuple[int, int, int, int]]: if not self._crop_item or not self._crop_item.isVisible() or not self._pix_item: return None pix = self._pix_item.pixmap() if pix.width() <= 0 or pix.height() <= 0: return None r = self._crop_item.rect() pos = self._crop_item.pos() x1 = r.left() + pos.x() y1 = r.top() + pos.y() x2 = r.right() + pos.x() y2 = r.bottom() + pos.y() sx = self._img_w / pix.width() sy = self._img_h / pix.height() return ( max(0, int(round(x1 * sx))), max(0, int(round(y1 * sy))), min(self._img_w, int(round(x2 * sx))), min(self._img_h, int(round(y2 * sy))), ) def resizeEvent(self, event): super().resizeEvent(event) self._fit_image() # ── Brush mode ──────────────────────────────────────────────────── def set_brush_mode(self, active: bool): self._brush_active = active def _scene_to_original(self, sp: QPointF) -> Optional[Tuple[int, int]]: """Map a scene point to original-image pixel coordinates.""" if not self._pix_item or self._img_w <= 0: return None pix = self._pix_item.pixmap() if pix.width() <= 0: return None sx = self._img_w / pix.width() sy = self._img_h / pix.height() ox = int(round(sp.x() * sx)) oy = int(round(sp.y() * sy)) return (max(0, min(self._img_w - 1, ox)), max(0, min(self._img_h - 1, oy))) def mousePressEvent(self, event): if self._brush_active and event.button() == Qt.LeftButton: self._brush_points = [] sp = self.mapToScene(event.pos()) pt = self._scene_to_original(sp) if pt: self._brush_points.append(pt) event.accept() return if event.button() == Qt.LeftButton and self._pix_item: if self._crop_item and self._crop_item.isVisible(): item = self.itemAt(event.pos()) if item is self._crop_item: super().mousePressEvent(event) return if self._crop_item is None: pix = self._pix_item.pixmap() tiny = QRectF(0, 0, 2, 2) self._crop_item = CropOverlay(tiny, self._scene.sceneRect()) self._scene.addItem(self._crop_item) if self._pending_aspect is not None: self._crop_item.set_aspect(self._pending_aspect) if self._pending_circle: self._crop_item.set_circle(True) self._crop_item.setVisible(True) self._draw_start = self.mapToScene(event.pos()) self._crop_item.setPos(0, 0) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event): if self._brush_active and self._brush_points is not None: sp = self.mapToScene(event.pos()) pt = self._scene_to_original(sp) if pt: self._brush_points.append(pt) event.accept() return if self._draw_start is not None and self._crop_item: end = self.mapToScene(event.pos()) start = self._draw_start w = end.x() - start.x() h = end.y() - start.y() asp = self._pending_aspect if asp and asp > 0: if abs(h) < 1: h = 1.0 if abs(w) / max(abs(h), 1e-6) > asp: h = abs(w) / asp * (1.0 if h >= 0 else -1.0) else: w = abs(h) * asp * (1.0 if w >= 0 else -1.0) x = min(start.x(), start.x() + w) y = min(start.y(), start.y() + h) self._crop_item.prepareGeometryChange() self._crop_item.setRect(QRectF(x, y, max(abs(w), 2), max(abs(h), 2))) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if self._brush_active and self._brush_points: self.brush_painted.emit(list(self._brush_points)) self._brush_points = [] event.accept() return if self._draw_start is not None: end = self.mapToScene(event.pos()) dx = abs(end.x() - self._draw_start.x()) dy = abs(end.y() - self._draw_start.y()) if dx < 5 and dy < 5: self.hide_crop_overlay() self._draw_start = None event.accept() return super().mouseReleaseEvent(event) def keyPressEvent(self, event): if event.key() in (Qt.Key_Return, Qt.Key_Enter): if self._crop_item and self._crop_item.isVisible(): self.crop_changed.emit() event.accept() return super().keyPressEvent(event) # ─── Reorderable list widget (drag & drop with setItemWidget) ─────────────── class _ChainListWidget(QListWidget): order_changed = Signal() def __init__(self, parent=None): super().__init__(parent) self.setDragDropMode(QAbstractItemView.InternalMove) self.setDefaultDropAction(Qt.MoveAction) def dropEvent(self, event): super().dropEvent(event) self.order_changed.emit() # ─── Segmentation worker (background thread) ──────────────────────────────── # ─── Segmentation worker (background thread) ──────────────────────────────── class SegmentationWorker(QObject): finished = Signal(object) error = Signal(str) status = Signal(str) def __init__(self, img: Image.Image): super().__init__() self._img = img def run(self): try: def _status(msg: str): self.status.emit(msg) mask = segment_person(self._img, progress_cb=_status) self.finished.emit(mask) except Exception as e: self.error.emit(str(e)) # ─── Main window ──────────────────────────────────────────────────────────── class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("FotoApp \u2013 Praxis Lindengut AG") self.resize(1200, 800) self.setStyleSheet(DARK_STYLE) self._files: List[Path] = [] self._current_idx: int = -1 self._current_img: Optional[Image.Image] = None self._lut_chain: List[dict] = [] self._out_dir: Path = Path.home() / "Pictures" / "neue Fotos" self._last_image_dir: Path = Path.home() self._last_lut_dir: Path = Path.home() self._is_circle_crop = False self._looks: Dict[str, dict] = {} self._rebuilding_chain = False # KI segmentation state self._seg_mask: Optional[np.ndarray] = None self._seg_active: bool = False self._seg_thread: Optional[QThread] = None self._seg_worker: Optional[SegmentationWorker] = None self._mask_history = MaskHistory(maxlen=20) self._brush_mode: bool = False self._brush_add: bool = True self._bg_image: Optional[np.ndarray] = None self._bg_color: Tuple[int, int, int] = (255, 255, 255) # Undo / Redo self._undo_stack: list[dict] = [] self._redo_stack: list[dict] = [] self._undo_recording = True self._preview_timer = QTimer(self) self._preview_timer.setSingleShot(True) self._preview_timer.setInterval(150) self._preview_timer.timeout.connect(self._update_processed_preview) LUT_DIR.mkdir(parents=True, exist_ok=True) self._load_config() self._crop_presets = [ ("Frei", 0, 0), ("Quadrat 1:1", 1, 1), ("Portrait 4:5", 4, 5), ("Portrait 3:4", 3, 4), ("Portrait 2:3", 2, 3), ("Landscape 3:2", 3, 2), ("Landscape 16:9", 16, 9), ("Passfoto 3.5:4.5", 3.5, 4.5), ("Rund", 1, 1), ] self._build_ui() self._apply_saved_state() self._restore_window_geometry() self._push_undo_snapshot() # ── Build UI ───────────────────────────────────────────────────────── def _build_ui(self): central = QWidget() self.setCentralWidget(central) main_lay = QHBoxLayout(central) main_lay.setSpacing(10) main_lay.setContentsMargins(10, 10, 10, 10) splitter = QSplitter(Qt.Horizontal) main_lay.addWidget(splitter) # ── LEFT ───────────────────────────────────────────────────── left = QWidget() left.setMaximumWidth(180) left_lay = QVBoxLayout(left) left_lay.setContentsMargins(0, 0, 0, 0) self.drop_area = DropArea() self.drop_area.files_dropped.connect(self._add_files) left_lay.addWidget(self.drop_area) btn_row = QHBoxLayout() btn_load = QPushButton("Fotos laden\u2026") btn_load.clicked.connect(self._on_load_files) btn_row.addWidget(btn_load) btn_clear = QPushButton("Leeren") btn_clear.clicked.connect(self._on_clear) btn_row.addWidget(btn_clear) left_lay.addLayout(btn_row) self.file_list = QListWidget() self.file_list.currentRowChanged.connect(self._on_file_selected) left_lay.addWidget(self.file_list, 1) self.lbl_count = QLabel("0 Fotos") self.lbl_count.setStyleSheet("color:#888; padding:4px;") left_lay.addWidget(self.lbl_count) splitter.addWidget(left) # ── CENTER (Vorschau + Vergleich + Speichern-Leiste) ───────── center = QWidget() center_lay = QVBoxLayout(center) center_lay.setContentsMargins(0, 0, 0, 0) center_lay.setSpacing(4) self.preview = CropPreviewView() self.preview.brush_painted.connect(self._on_brush_painted) self.preview.crop_changed.connect(self._on_apply_crop_preview) center_lay.addWidget(self.preview, 1) # Undo / Redo bar undo_bar = QHBoxLayout() undo_bar.setContentsMargins(4, 0, 4, 0) self.btn_undo = QPushButton("\u25C0 Rückgängig") self.btn_undo.setEnabled(False) self.btn_undo.setStyleSheet( "QPushButton { background:#2a2a3a; border:1px solid #444; border-radius:4px; padding:4px 12px; }" "QPushButton:hover { background:#3a3a5a; }" "QPushButton:disabled { color:#555; background:#1a1a2a; }" ) self.btn_undo.clicked.connect(self._on_undo) undo_bar.addWidget(self.btn_undo) self.btn_redo = QPushButton("Wiederherstellen \u25B6") self.btn_redo.setEnabled(False) self.btn_redo.setStyleSheet( "QPushButton { background:#2a2a3a; border:1px solid #444; border-radius:4px; padding:4px 12px; }" "QPushButton:hover { background:#3a3a5a; }" "QPushButton:disabled { color:#555; background:#1a1a2a; }" ) self.btn_redo.clicked.connect(self._on_redo) undo_bar.addWidget(self.btn_redo) undo_bar.addStretch(1) center_lay.addLayout(undo_bar) # Side-by-side comparison (hidden by default) self._compare_widget = QWidget() compare_lay = QHBoxLayout(self._compare_widget) compare_lay.setContentsMargins(0, 0, 0, 0) compare_lay.setSpacing(6) left_cmp = QVBoxLayout() lbl_orig_title = QLabel("Original") lbl_orig_title.setAlignment(Qt.AlignCenter) lbl_orig_title.setStyleSheet("color:#888; font-size:12px; font-weight:bold;") left_cmp.addWidget(lbl_orig_title) self.cmp_original = QLabel() self.cmp_original.setAlignment(Qt.AlignCenter) self.cmp_original.setStyleSheet( "QLabel { background:#111; border:1px solid #333; border-radius:6px; }" ) left_cmp.addWidget(self.cmp_original, 1) compare_lay.addLayout(left_cmp, 1) right_cmp = QVBoxLayout() lbl_edit_title = QLabel("Bearbeitet") lbl_edit_title.setAlignment(Qt.AlignCenter) lbl_edit_title.setStyleSheet("color:#888; font-size:12px; font-weight:bold;") right_cmp.addWidget(lbl_edit_title) self.cmp_edited = QLabel() self.cmp_edited.setAlignment(Qt.AlignCenter) self.cmp_edited.setStyleSheet( "QLabel { background:#111; border:1px solid #333; border-radius:6px; }" ) right_cmp.addWidget(self.cmp_edited, 1) compare_lay.addLayout(right_cmp, 1) self._compare_widget.setVisible(False) center_lay.addWidget(self._compare_widget, 1) # Compare toggle button self.btn_compare = QPushButton("Vorher / Nachher vergleichen") self.btn_compare.setCheckable(True) self.btn_compare.setStyleSheet( "QPushButton { background:#1a2a3a; border:1px solid #334; border-radius:6px;" " padding:6px; font-weight:bold; }" "QPushButton:hover { background:#2a3a5a; }" "QPushButton:checked { background:#2a5a3a; border-color:#4a8a5a; }" ) self.btn_compare.toggled.connect(self._on_compare_toggled) center_lay.addWidget(self.btn_compare) splitter.addWidget(center) # ── RIGHT ──────────────────────────────────────────────────── right_scroll = QScrollArea() right_scroll.setWidgetResizable(True) right_scroll.setMinimumWidth(380) right_scroll.setMaximumWidth(520) right = QWidget() right.setStyleSheet( "QGroupBox { font-weight: bold; }" "QSlider { min-height: 22px; }" ) rlay = QVBoxLayout(right) rlay.setContentsMargins(6, 6, 6, 6) rlay.setSpacing(6) # ── Crop ───────────────────────────────────────────── g_crop = QGroupBox("Zuschnitt") crop_lay = QVBoxLayout(g_crop) row1 = QHBoxLayout() row1.addWidget(QLabel("Preset:")) self.combo_crop = QComboBox() for name, _, _ in self._crop_presets: self.combo_crop.addItem(name) self.combo_crop.currentIndexChanged.connect(self._on_crop_preset_changed) row1.addWidget(self.combo_crop, 1) crop_lay.addLayout(row1) self.chk_use_cm = QCheckBox("cm-Ma\u00dfe f\u00fcr Export verwenden") self.chk_use_cm.setChecked(False) self.chk_use_cm.toggled.connect(self._on_use_cm_toggled) crop_lay.addWidget(self.chk_use_cm) self._cm_container = QWidget() cm_inner = QVBoxLayout(self._cm_container) cm_inner.setContentsMargins(0, 0, 0, 0) cm_inner.setSpacing(4) row_cm = QHBoxLayout() row_cm.addWidget(QLabel("B (cm):")) self.spin_w_cm = QDoubleSpinBox() self.spin_w_cm.setRange(0.5, 100.0) self.spin_w_cm.setValue(5.0) self.spin_w_cm.setSingleStep(0.5) self.spin_w_cm.setDecimals(1) self.spin_w_cm.valueChanged.connect(self._on_cm_changed) row_cm.addWidget(self.spin_w_cm) row_cm.addWidget(QLabel("H (cm):")) self.spin_h_cm = QDoubleSpinBox() self.spin_h_cm.setRange(0.5, 100.0) self.spin_h_cm.setValue(5.0) self.spin_h_cm.setSingleStep(0.5) self.spin_h_cm.setDecimals(1) self.spin_h_cm.valueChanged.connect(self._on_cm_changed) row_cm.addWidget(self.spin_h_cm) cm_inner.addLayout(row_cm) row_dpi = QHBoxLayout() row_dpi.addWidget(QLabel("DPI:")) self.spin_dpi = QSpinBox() self.spin_dpi.setRange(72, 1200) self.spin_dpi.setValue(300) self.spin_dpi.valueChanged.connect(self._on_cm_changed) row_dpi.addWidget(self.spin_dpi) self.lbl_px_out = QLabel("") self.lbl_px_out.setStyleSheet("color:#aaa; font-size:11px;") row_dpi.addWidget(self.lbl_px_out, 1) cm_inner.addLayout(row_dpi) self._cm_container.setVisible(False) crop_lay.addWidget(self._cm_container) row_circle = QHBoxLayout() self.lbl_circle_diam = QLabel("Durchmesser (cm):") row_circle.addWidget(self.lbl_circle_diam) self.spin_circle_diam = QDoubleSpinBox() self.spin_circle_diam.setRange(0.5, 100.0) self.spin_circle_diam.setValue(5.0) self.spin_circle_diam.setSingleStep(0.5) self.spin_circle_diam.setDecimals(1) self.spin_circle_diam.valueChanged.connect(self._on_cm_changed) row_circle.addWidget(self.spin_circle_diam) self.lbl_circle_px = QLabel("") self.lbl_circle_px.setStyleSheet("color:#aaa; font-size:11px;") row_circle.addWidget(self.lbl_circle_px) crop_lay.addLayout(row_circle) self.lbl_circle_diam.setVisible(False) self.spin_circle_diam.setVisible(False) self.lbl_circle_px.setVisible(False) self.btn_apply_crop = QPushButton("Zuschnitt anwenden") self.btn_apply_crop.setStyleSheet( "QPushButton { background:#1a3a1a; } QPushButton:hover { background:#2a5a2a; }" ) self.btn_apply_crop.clicked.connect(self._on_apply_crop_preview) crop_lay.addWidget(self.btn_apply_crop) rlay.addWidget(g_crop) btn_reset_all = QPushButton("Alles zur\u00fccksetzen") btn_reset_all.setStyleSheet( "QPushButton { background:#4a1a1a; font-weight:bold; padding:6px; }" "QPushButton:hover { background:#6a2a2a; }" ) btn_reset_all.clicked.connect(self._on_reset_all) rlay.addWidget(btn_reset_all) # ── Anpassungen ────────────────────────────────────── g_adj = QGroupBox("Anpassungen") adj_lay = QVBoxLayout(g_adj) self.sld_bright = LabeledSlider("Helligkeit", -100, 100, 0) self.sld_bright.valueChanged.connect(self._on_settings_changed) adj_lay.addWidget(self.sld_bright) self.sld_contrast = LabeledSlider("Kontrast", -100, 100, 0) self.sld_contrast.valueChanged.connect(self._on_settings_changed) adj_lay.addWidget(self.sld_contrast) self.sld_sat = LabeledSlider("S\u00e4ttigung", -100, 100, 0) self.sld_sat.valueChanged.connect(self._on_settings_changed) adj_lay.addWidget(self.sld_sat) self.sld_temp = LabeledSlider("Temperatur", -50, 50, 0) self.sld_temp.valueChanged.connect(self._on_settings_changed) adj_lay.addWidget(self.sld_temp) self.sld_tint = LabeledSlider("Tint", -50, 50, 0) self.sld_tint.valueChanged.connect(self._on_settings_changed) adj_lay.addWidget(self.sld_tint) self.sld_teal = LabeledSlider("Teal&Orange", 0, 100, 0, "%") self.sld_teal.valueChanged.connect(self._on_settings_changed) adj_lay.addWidget(self.sld_teal) btn_reset = QPushButton("Regler zur\u00fccksetzen") btn_reset.clicked.connect(self._on_reset_sliders) adj_lay.addWidget(btn_reset) rlay.addWidget(g_adj) self._make_collapsible(g_adj, "adj", default_open=False) # ── Looks ──────────────────────────────────────────── g_looks = QGroupBox("Looks") looks_lay = QVBoxLayout(g_looks) looks_btn = QHBoxLayout() for txt, slot, bg in [ ("+", self._on_add_look, "#1a4a1a"), ("\u2212", self._on_remove_look, "#4a1a1a"), ]: b = QPushButton(txt) b.setFixedWidth(40) b.setStyleSheet( f"QPushButton {{ background:{bg}; font-size:18px; font-weight:bold; padding:4px; }}" ) b.clicked.connect(slot) looks_btn.addWidget(b) looks_btn.addStretch() looks_lay.addLayout(looks_btn) self.looks_list = QListWidget() self.looks_list.setMaximumHeight(100) self.looks_list.itemClicked.connect(self._on_look_selected) looks_lay.addWidget(self.looks_list) rlay.addWidget(g_looks) self._make_collapsible(g_looks, "looks", default_open=False) # ── LUT-Kette ─────────────────────────────────────── g_lut = QGroupBox("LUT-Kette") lut_lay = QVBoxLayout(g_lut) lut_btn = QHBoxLayout() for txt, slot, bg, tip in [ ("+", self._on_add_chain_lut, "#1a4a1a", "LUT hinzuf\u00fcgen"), ("\u2212", self._on_remove_chain_lut, "#4a1a1a", "LUT entfernen"), ("\u25b2", self._on_chain_move_up, "#1a1a1a", "Nach oben"), ("\u25bc", self._on_chain_move_down, "#1a1a1a", "Nach unten"), ]: b = QPushButton(txt) b.setFixedWidth(40) b.setToolTip(tip) b.setStyleSheet( f"QPushButton {{ background:{bg}; font-size:16px; font-weight:bold; padding:4px; }}" ) b.clicked.connect(slot) lut_btn.addWidget(b) lut_btn.addStretch() lut_lay.addLayout(lut_btn) self.lut_chain_list = _ChainListWidget() self.lut_chain_list.setMinimumHeight(160) self.lut_chain_list.order_changed.connect(self._on_chain_reordered) lut_lay.addWidget(self.lut_chain_list) rlay.addWidget(g_lut) self._make_collapsible(g_lut, "lut", default_open=False) # ── Farbmischer (nach LUT) ────────────────────────── g_color = QGroupBox("Farbmischer (nach LUT)") color_lay = QVBoxLayout(g_color) self._hue_sliders: dict[str, LabeledSlider] = {} for band in HUE_BAND_NAMES: sld = LabeledSlider(_HUE_LABELS[band], -100, 100, 0) sld.valueChanged.connect(self._on_settings_changed) color_lay.addWidget(sld) self._hue_sliders[band] = sld btn_reset_hue = QPushButton("Farbmischer zur\u00fccksetzen") btn_reset_hue.clicked.connect(self._on_reset_hue_bands) color_lay.addWidget(btn_reset_hue) rlay.addWidget(g_color) self._make_collapsible(g_color, "color", default_open=False) # ── Nachbearbeitung (nach LUT) ─────────────────────── g_post = QGroupBox("Nachbearbeitung (nach LUT)") post_lay = QVBoxLayout(g_post) self.sld_post_bright = LabeledSlider("Helligkeit", -100, 100, 0) self.sld_post_bright.valueChanged.connect(self._on_settings_changed) post_lay.addWidget(self.sld_post_bright) self.sld_post_contrast = LabeledSlider("Kontrast", -100, 100, 0) self.sld_post_contrast.valueChanged.connect(self._on_settings_changed) post_lay.addWidget(self.sld_post_contrast) self.sld_post_blacks = LabeledSlider("Schwarz", -100, 100, 0) self.sld_post_blacks.valueChanged.connect(self._on_settings_changed) post_lay.addWidget(self.sld_post_blacks) self.sld_post_sharpen = LabeledSlider("Schärfe", 0, 100, 0) self.sld_post_sharpen.valueChanged.connect(self._on_settings_changed) post_lay.addWidget(self.sld_post_sharpen) btn_reset_post = QPushButton("Nachbearbeitung zur\u00fccksetzen") btn_reset_post.clicked.connect(self._on_reset_post) post_lay.addWidget(btn_reset_post) rlay.addWidget(g_post) self._make_collapsible(g_post, "post", default_open=True) # ── KI-Freistellen ──────────────────────────────────── g_seg = QGroupBox("KI-Freistellen") seg_lay = QVBoxLayout(g_seg) self.btn_segment = QPushButton("Person freistellen (KI)") self.btn_segment.setStyleSheet( "QPushButton { background:#1a3a5a; font-weight:bold; }" "QPushButton:hover { background:#2a5a8a; }" ) self.btn_segment.clicked.connect(self._on_segment) seg_lay.addWidget(self.btn_segment) self.seg_progress = QProgressBar() self.seg_progress.setRange(0, 0) self.seg_progress.setVisible(False) seg_lay.addWidget(self.seg_progress) self.seg_status = QLabel("") self.seg_status.setStyleSheet("color:#888; font-size:11px;") seg_lay.addWidget(self.seg_status) self.btn_seg_clear = QPushButton("Freistellung entfernen") self.btn_seg_clear.setStyleSheet( "QPushButton { background:#4a1a1a; }" "QPushButton:hover { background:#6a2a2a; }" ) self.btn_seg_clear.clicked.connect(self._on_seg_clear) seg_lay.addWidget(self.btn_seg_clear) self.sld_feather = LabeledSlider("Kanten weichzeichnen", 0, 100, 0, "px") self.sld_feather.valueChanged.connect(self._on_settings_changed) seg_lay.addWidget(self.sld_feather) seg_lay.addWidget(QLabel("Hintergrund:")) self.combo_bg = QComboBox() self.combo_bg.addItems([ "Original", "Unscharf (Blur)", "Einfarbig", "Transparent (PNG)", "Eigenes Bild", ]) self.combo_bg.currentIndexChanged.connect(self._on_bg_mode_changed) seg_lay.addWidget(self.combo_bg) self.sld_bg_blur = LabeledSlider("Hintergrund-Unschärfe", 0, 100, 25, "px") self.sld_bg_blur.valueChanged.connect(self._on_settings_changed) self.sld_bg_blur.setVisible(False) seg_lay.addWidget(self.sld_bg_blur) bg_color_row = QHBoxLayout() self.btn_bg_color = QPushButton("Farbe wählen") self.btn_bg_color.clicked.connect(self._on_pick_bg_color) self.btn_bg_color.setVisible(False) bg_color_row.addWidget(self.btn_bg_color) self.lbl_bg_color = QLabel("") self.lbl_bg_color.setFixedSize(24, 24) self.lbl_bg_color.setVisible(False) bg_color_row.addWidget(self.lbl_bg_color) bg_color_row.addStretch() seg_lay.addLayout(bg_color_row) self.btn_bg_image = QPushButton("Hintergrundbild laden…") self.btn_bg_image.clicked.connect(self._on_load_bg_image) self.btn_bg_image.setVisible(False) seg_lay.addWidget(self.btn_bg_image) seg_lay.addWidget(QLabel("Maske bearbeiten:")) brush_row = QHBoxLayout() self.chk_brush = QCheckBox("Pinsel aktiv") self.chk_brush.toggled.connect(self._on_brush_toggled) brush_row.addWidget(self.chk_brush) self.rb_brush_add = QRadioButton("+") self.rb_brush_add.setChecked(True) self.rb_brush_add.setToolTip("Maske hinzufügen") self.rb_brush_add.toggled.connect( lambda chk: setattr(self, "_brush_add", chk)) brush_row.addWidget(self.rb_brush_add) self.rb_brush_rem = QRadioButton("\u2212") self.rb_brush_rem.setToolTip("Maske entfernen") brush_row.addWidget(self.rb_brush_rem) seg_lay.addLayout(brush_row) self.sld_brush_size = LabeledSlider("Pinselgröße", 2, 100, 20, "px") seg_lay.addWidget(self.sld_brush_size) self.sld_brush_hard = LabeledSlider("Pinselhärte", 0, 100, 70, "%") seg_lay.addWidget(self.sld_brush_hard) self.btn_mask_undo = QPushButton("Maske: Rückgängig") self.btn_mask_undo.clicked.connect(self._on_mask_undo) seg_lay.addWidget(self.btn_mask_undo) rlay.addWidget(g_seg) self._make_collapsible(g_seg, "seg", default_open=False) # ── Retusche ────────────────────────────────────────── g_ret = QGroupBox("Retusche") ret_lay = QVBoxLayout(g_ret) self.sld_skin_soft = LabeledSlider("Haut weichzeichnen", 0, 100, 0, "%") self.sld_skin_soft.valueChanged.connect(self._on_settings_changed) ret_lay.addWidget(self.sld_skin_soft) rlay.addWidget(g_ret) self._make_collapsible(g_ret, "ret", default_open=False) # ── Speichern ──────────────────────────────────────── g_save = QGroupBox("Speichern") save_lay = QVBoxLayout(g_save) fmt_row = QHBoxLayout() fmt_row.addWidget(QLabel("Format:")) self.combo_fmt = QComboBox() self.combo_fmt.addItems(["JPG", "PNG"]) fmt_row.addWidget(self.combo_fmt) fmt_row.addWidget(QLabel("Qual.:")) self.spin_quality = QSpinBox() self.spin_quality.setRange(50, 100) self.spin_quality.setValue(95) fmt_row.addWidget(self.spin_quality) fmt_row.addStretch() save_lay.addLayout(fmt_row) self.btn_dir = QPushButton("Zielordner\u2026") self.btn_dir.clicked.connect(self._on_choose_dir) save_lay.addWidget(self.btn_dir) self.lbl_dir = QLabel(str(self._out_dir)) self.lbl_dir.setWordWrap(True) self.lbl_dir.setStyleSheet("color:#aaa; font-size:11px; padding:2px;") save_lay.addWidget(self.lbl_dir) self.btn_save_current = QPushButton("Aktuelles Foto als Neues speichern") self.btn_save_current.clicked.connect(self._on_save_current_new) self.btn_save_current.setEnabled(False) save_lay.addWidget(self.btn_save_current) self.btn_overwrite = QPushButton("Aktuelles Foto \u00fcberschreiben") self.btn_overwrite.setStyleSheet( "QPushButton { background:#553300; } QPushButton:hover { background:#774400; }" ) self.btn_overwrite.clicked.connect(self._on_overwrite_current) self.btn_overwrite.setEnabled(False) save_lay.addWidget(self.btn_overwrite) self.btn_batch = QPushButton("ALLE als neue Fotos speichern") self.btn_batch.setStyleSheet( "QPushButton { background:#006688; font-size:14px; font-weight:bold; padding:10px; }" "QPushButton:hover { background:#008aaa; }" "QPushButton:disabled { background:#2a2a2a; color:#666; }" ) self.btn_batch.clicked.connect(self._on_batch_save) self.btn_batch.setEnabled(False) save_lay.addWidget(self.btn_batch) self.progress = QProgressBar() self.progress.setVisible(False) save_lay.addWidget(self.progress) btn_save_cfg = QPushButton("Einstellung speichern") btn_save_cfg.setStyleSheet( "QPushButton { background:#1a3a1a; } QPushButton:hover { background:#2a5a2a; }" ) btn_save_cfg.clicked.connect(self._save_config) save_lay.addWidget(btn_save_cfg) rlay.addStretch(1) right_scroll.setWidget(right) center_lay.addWidget(g_save) splitter.addWidget(right_scroll) splitter.setStretchFactor(0, 2) splitter.setStretchFactor(1, 5) splitter.setStretchFactor(2, 0) self._on_cm_changed() # ── Collapsible sections ────────────────────────────────────────────── _COLLAPSIBLE_KEYS: list[str] = [] def _make_collapsible(self, gbox: QGroupBox, key: str, default_open: bool = False): """Turn a QGroupBox into a collapsible section (toggle via checkbox).""" gbox.setCheckable(True) saved = self._config.get("collapsed", {}) is_open = saved.get(key, default_open) gbox.setChecked(is_open) self._toggle_group_content(gbox, is_open) gbox.toggled.connect(lambda chk, g=gbox: self._toggle_group_content(g, chk)) if not hasattr(self, "_collapsible_map"): self._collapsible_map: dict[str, QGroupBox] = {} self._collapsible_map[key] = gbox if key not in self._COLLAPSIBLE_KEYS: self._COLLAPSIBLE_KEYS.append(key) @staticmethod def _toggle_group_content(gbox: QGroupBox, visible: bool): layout = gbox.layout() if not layout: return for i in range(layout.count()): item = layout.itemAt(i) w = item.widget() if w: w.setVisible(visible) elif item.layout(): MainWindow._set_layout_visible(item.layout(), visible) @staticmethod def _set_layout_visible(lay, visible: bool): for i in range(lay.count()): item = lay.itemAt(i) w = item.widget() if w: w.setVisible(visible) elif item.layout(): MainWindow._set_layout_visible(item.layout(), visible) def _read_collapsed_state(self) -> dict: result = {} for key, gbox in getattr(self, "_collapsible_map", {}).items(): result[key] = gbox.isChecked() return result # ── Helpers ────────────────────────────────────────────────────────── def _update_count(self): n = len(self._files) self.lbl_count.setText(f"{n} Foto{'s' if n != 1 else ''}") self.btn_batch.setEnabled(n > 0) has_sel = self._current_idx >= 0 self.btn_save_current.setEnabled(has_sel) self.btn_overwrite.setEnabled(has_sel) def _read_adj(self) -> Adjustments: return Adjustments( brightness=self.sld_bright.value(), contrast=self.sld_contrast.value(), saturation=self.sld_sat.value(), temperature=self.sld_temp.value(), tint=self.sld_tint.value(), teal_orange=self.sld_teal.value(), ) def _read_hue_bands(self) -> dict: return {band: self._hue_sliders[band].value() for band in HUE_BAND_NAMES} def _read_post_adj(self) -> dict: return { "brightness": self.sld_post_bright.value(), "contrast": self.sld_post_contrast.value(), "blacks": self.sld_post_blacks.value(), "sharpen": self.sld_post_sharpen.value(), } def _circle_diameter_px(self) -> int: if self._is_circle_crop and self.chk_use_cm.isChecked(): return cm_to_px(self.spin_circle_diam.value(), self.spin_dpi.value()) return 0 def _get_lut_chain_tuples(self) -> list: result = [] for entry in self._lut_chain: p = Path(entry["path"]) if not p.exists(): continue try: lut = get_cached_lut(p) result.append((lut, entry["strength"])) except Exception: continue return result def _process_image(self, img: Image.Image) -> Image.Image: crop_box = self.preview.get_crop_xyxy_original() if crop_box: img = crop_pil(img, crop_box) adj = self._read_adj() chain = self._get_lut_chain_tuples() bands = self._read_hue_bands() post = self._read_post_adj() img = apply_adjustments(img, adj, chain, bands) rgb = pil_to_rgb_float01(img) rgb = self._apply_seg_pipeline(rgb) rgb = apply_post_adj(rgb, post) img = rgb_float01_to_pil(rgb) if self._seg_active and self._get_bg_mode_str() == "transparent": img = self._make_transparent(img) if self.chk_use_cm.isChecked(): out_w = cm_to_px(self.spin_w_cm.value(), self.spin_dpi.value()) out_h = cm_to_px(self.spin_h_cm.value(), self.spin_dpi.value()) img = resize_to_output(img, out_w, out_h) if self._is_circle_crop: img = crop_circle(img, self._circle_diameter_px()) return img def _process_image_for_path(self, fpath: Path) -> Image.Image: img = ensure_srgb(Image.open(fpath)) idx = self.combo_crop.currentIndex() if 0 < idx < len(self._crop_presets): _, cw, ch = self._crop_presets[idx] if cw > 0 and ch > 0: img = crop_center_aspect(img, cw, ch) adj = self._read_adj() chain = self._get_lut_chain_tuples() bands = self._read_hue_bands() post = self._read_post_adj() img = apply_adjustments(img, adj, chain, bands) rgb = pil_to_rgb_float01(img) rgb = self._apply_seg_pipeline(rgb) rgb = apply_post_adj(rgb, post) img = rgb_float01_to_pil(rgb) if self._seg_active and self._get_bg_mode_str() == "transparent": img = self._make_transparent(img) if self.chk_use_cm.isChecked(): out_w = cm_to_px(self.spin_w_cm.value(), self.spin_dpi.value()) out_h = cm_to_px(self.spin_h_cm.value(), self.spin_dpi.value()) img = resize_to_output(img, out_w, out_h) if self._is_circle_crop: img = crop_circle(img, self._circle_diameter_px()) return img def _make_transparent(self, img: Image.Image) -> Image.Image: """Apply the segmentation mask as alpha channel for transparent export.""" if self._seg_mask is None: return img w, h = img.size mask = self._seg_mask.copy() if mask.shape[:2] != (h, w): mask_pil = Image.fromarray((mask * 255).astype(np.uint8), mode="L") mask_pil = mask_pil.resize((w, h), Image.Resampling.LANCZOS) mask = np.asarray(mask_pil).astype(np.float32) / 255.0 feather_r = self.sld_feather.value() if feather_r > 0: mask = feather_mask(mask, feather_r) alpha = Image.fromarray((mask * 255).astype(np.uint8), mode="L") rgba = img.convert("RGBA") rgba.putalpha(alpha) return rgba def _get_ext(self) -> str: if self._is_circle_crop: return ".png" if self._seg_active and self._get_bg_mode_str() == "transparent": return ".png" return ".jpg" if self.combo_fmt.currentText() == "JPG" else ".png" def _save_image(self, img: Image.Image, path: Path): if self._is_circle_crop or img.mode == "RGBA": img.save(path, format="PNG") return fmt = self.combo_fmt.currentText().upper() if fmt == "JPG": if img.mode != "RGB": img = img.convert("RGB") img.save(path, format="JPEG", quality=self.spin_quality.value(), subsampling=0) else: img.save(path, format="PNG") def _pil_to_qpixmap(self, img: Image.Image) -> QPixmap: qimg = ImageQt.ImageQt(img.convert("RGB")) return QPixmap.fromImage(qimg) def _pil_to_qpixmap_rgba(self, img: Image.Image) -> QPixmap: """Convert PIL image to QPixmap, preserving alpha for round crops.""" if img.mode == "RGBA": qimg = ImageQt.ImageQt(img) else: qimg = ImageQt.ImageQt(img.convert("RGB")) return QPixmap.fromImage(qimg) # ── Crop controls ──────────────────────────────────────────────────── def _on_crop_preset_changed(self, idx: int): if idx < 0 or idx >= len(self._crop_presets): return name, cw, ch = self._crop_presets[idx] self._is_circle_crop = (name == "Rund") self.lbl_circle_diam.setVisible(self._is_circle_crop) self.spin_circle_diam.setVisible(self._is_circle_crop) self.lbl_circle_px.setVisible(self._is_circle_crop) if cw > 0 and ch > 0: self.preview.set_crop_aspect(cw / ch) self.spin_w_cm.blockSignals(True) self.spin_h_cm.blockSignals(True) if name == "Passfoto 3.5:4.5": self.spin_w_cm.setValue(3.5) self.spin_h_cm.setValue(4.5) elif cw == ch: self.spin_w_cm.setValue(5.0) self.spin_h_cm.setValue(5.0) self.spin_w_cm.blockSignals(False) self.spin_h_cm.blockSignals(False) else: self.preview.set_crop_aspect(None) self.preview.set_circle_mode(self._is_circle_crop) self._on_cm_changed() if idx > 0 and self._current_img is not None: self.preview.show_crop_overlay() elif idx == 0: self.preview.hide_crop_overlay() self._preview_timer.start() def _on_use_cm_toggled(self, checked: bool): self._cm_container.setVisible(checked) self._on_cm_changed() def _on_cm_changed(self, *_): w = cm_to_px(self.spin_w_cm.value(), self.spin_dpi.value()) h = cm_to_px(self.spin_h_cm.value(), self.spin_dpi.value()) self.lbl_px_out.setText(f"{w}\u00d7{h} px") if self._is_circle_crop: d_px = cm_to_px(self.spin_circle_diam.value(), self.spin_dpi.value()) self.lbl_circle_px.setText(f"= {d_px} px") def _on_apply_crop_preview(self): if self._current_img is None: return if not self.preview.is_crop_visible(): self.preview.show_crop_overlay() idx = self.combo_crop.currentIndex() if 0 < idx < len(self._crop_presets): _, cw, ch = self._crop_presets[idx] if cw > 0 and ch > 0: self.preview.set_crop_aspect(cw / ch) self.preview.set_circle_mode(self._is_circle_crop) return try: img = self._current_img.copy() crop_box = self.preview.get_crop_xyxy_original() if crop_box: img = crop_pil(img, crop_box) self._current_img = img preview = img.copy() preview.thumbnail((1600, 1600), Image.Resampling.LANCZOS) adj = self._read_adj() chain = self._get_lut_chain_tuples() bands = self._read_hue_bands() preview = apply_adjustments(preview, adj, chain, bands) if self._is_circle_crop: preview = crop_circle(preview, self._circle_diameter_px()) pix = self._pil_to_qpixmap( preview.convert("RGB") if preview.mode == "RGBA" else preview ) self.preview.set_image(pix, img.width, img.height) except Exception: pass # ── Config persistence (APPDATA) ───────────────────────────────────── def _load_config(self): self._config: dict = {} if CONFIG_PATH.exists(): try: self._config = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) except Exception: pass elif (LUT_DIR.parent / "settings.json").exists(): try: old = json.loads( (LUT_DIR.parent / "settings.json").read_text(encoding="utf-8") ) self._config = { "last_image_dir": old.get("last_browse_dir", ""), "out_dir": old.get("out_dir", ""), } except Exception: pass d = self._config.get("last_image_dir", "") if d and Path(d).exists(): self._last_image_dir = Path(d) d = self._config.get("last_lut_dir", "") if d and Path(d).exists(): self._last_lut_dir = Path(d) d = self._config.get("out_dir", "") if d: self._out_dir = Path(d) self._looks = self._config.get("looks", {}) def _save_config(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) geo = self.geometry() data = { "last_image_dir": str(self._last_image_dir), "last_lut_dir": str(self._last_lut_dir), "out_dir": str(self._out_dir), "app_state": self._current_state_data(), "looks": self._looks, "collapsed": self._read_collapsed_state(), "window": { "x": geo.x(), "y": geo.y(), "w": geo.width(), "h": geo.height(), "maximized": self.isMaximized(), }, } CONFIG_PATH.write_text( json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" ) def _restore_window_geometry(self): win = self._config.get("window") if not win: return from PySide6.QtWidgets import QApplication screen = QApplication.primaryScreen() if screen: avail = screen.availableGeometry() x = max(avail.x(), min(win.get("x", 100), avail.right() - 200)) y = max(avail.y(), min(win.get("y", 100), avail.bottom() - 200)) else: x, y = win.get("x", 100), win.get("y", 100) w = max(600, win.get("w", 1200)) h = max(400, win.get("h", 800)) self.setGeometry(x, y, w, h) if win.get("maximized", False): self.showMaximized() def closeEvent(self, event): self._save_config() super().closeEvent(event) def _current_state_data(self) -> dict: return { "brightness": self.sld_bright.value(), "contrast": self.sld_contrast.value(), "saturation": self.sld_sat.value(), "temperature": self.sld_temp.value(), "tint": self.sld_tint.value(), "teal_orange": self.sld_teal.value(), "lut_chain": [ {"name": e["name"], "path": e["path"], "strength": e["strength"]} for e in self._lut_chain ], "hue_bands": self._read_hue_bands(), "post_brightness": self.sld_post_bright.value(), "post_contrast": self.sld_post_contrast.value(), "post_blacks": self.sld_post_blacks.value(), "post_sharpen": self.sld_post_sharpen.value(), "circle_diameter_cm": self.spin_circle_diam.value(), "crop_preset": self.combo_crop.currentIndex(), "format": self.combo_fmt.currentText(), "quality": self.spin_quality.value(), "dpi": self.spin_dpi.value(), "w_cm": self.spin_w_cm.value(), "h_cm": self.spin_h_cm.value(), "use_cm": self.chk_use_cm.isChecked(), "feather": self.sld_feather.value(), "bg_mode": self.combo_bg.currentIndex(), "bg_blur": self.sld_bg_blur.value(), "bg_color": list(self._bg_color), "skin_softening": self.sld_skin_soft.value(), } def _apply_state_data(self, data: dict, warn_missing_luts: bool = False): self.sld_bright.slider.setValue(data.get("brightness", 0)) self.sld_contrast.slider.setValue(data.get("contrast", 0)) self.sld_sat.slider.setValue(data.get("saturation", 0)) self.sld_temp.slider.setValue(data.get("temperature", 0)) self.sld_tint.slider.setValue(data.get("tint", 0)) self.sld_teal.slider.setValue(data.get("teal_orange", 0)) chain_data = data.get("lut_chain", []) self._lut_chain = [] warnings: list[str] = [] for entry in chain_data: p = Path(entry.get("path", "")) if not p.exists(): warnings.append(entry.get("name", "?")) continue self._lut_chain.append({ "name": entry["name"], "path": entry["path"], "strength": entry.get("strength", 50), }) self._rebuild_chain_list() if warn_missing_luts and warnings: QMessageBox.warning( self, "LUT-Warnung", "Folgende LUTs wurden nicht gefunden:\n" + "\n".join(warnings), ) bands = data.get("hue_bands", {}) for band in HUE_BAND_NAMES: self._hue_sliders[band].slider.setValue(bands.get(band, 0)) self.sld_post_bright.slider.setValue(data.get("post_brightness", 0)) self.sld_post_contrast.slider.setValue(data.get("post_contrast", 0)) self.sld_post_blacks.slider.setValue(data.get("post_blacks", 0)) self.sld_post_sharpen.slider.setValue(data.get("post_sharpen", 0)) self.spin_circle_diam.setValue(data.get("circle_diameter_cm", 5.0)) crop_idx = data.get("crop_preset", 0) if 0 <= crop_idx < self.combo_crop.count(): self.combo_crop.setCurrentIndex(crop_idx) fmt = data.get("format", "JPG") idx = self.combo_fmt.findText(fmt) if idx >= 0: self.combo_fmt.setCurrentIndex(idx) self.spin_quality.setValue(data.get("quality", 95)) self.spin_dpi.setValue(data.get("dpi", 300)) self.spin_w_cm.setValue(data.get("w_cm", 5.0)) self.spin_h_cm.setValue(data.get("h_cm", 5.0)) self.chk_use_cm.setChecked(data.get("use_cm", False)) self.sld_feather.slider.setValue(data.get("feather", 0)) bg_idx = data.get("bg_mode", 0) if 0 <= bg_idx < self.combo_bg.count(): self.combo_bg.setCurrentIndex(bg_idx) self.sld_bg_blur.slider.setValue(data.get("bg_blur", 25)) bc = data.get("bg_color", [255, 255, 255]) if isinstance(bc, list) and len(bc) == 3: self._bg_color = tuple(bc) self.sld_skin_soft.slider.setValue(data.get("skin_softening", 0)) def _apply_saved_state(self): state = self._config.get("app_state") if state: self._apply_state_data(state) self._refresh_looks_list() # ── File loading ───────────────────────────────────────────────────── def _add_files(self, paths: List[Path]): existing = {str(f) for f in self._files} first_new_row = len(self._files) added = 0 for p in paths: if str(p) not in existing: self._files.append(p) item = QListWidgetItem(p.name) item.setToolTip(str(p)) self.file_list.addItem(item) added += 1 if paths: self._last_image_dir = paths[0].parent self._update_count() if added > 0: self.file_list.setCurrentRow(first_new_row) def _on_load_files(self): files, _ = QFileDialog.getOpenFileNames( self, "Fotos laden", str(self._last_image_dir), "Bilder (*.png *.jpg *.jpeg *.bmp *.tif *.tiff *.webp);;Alle (*.*)", ) if files: self._add_files([Path(f) for f in files]) def _on_clear(self): self._files.clear() self.file_list.clear() self._current_idx = -1 self._current_img = None self.preview.clear_view() self.cmp_original.clear() self.cmp_edited.clear() self._update_count() # ── Preview ────────────────────────────────────────────────────────── def _on_file_selected(self, row: int): self._current_idx = row self._update_count() if row < 0 or row >= len(self._files): self._current_img = None self.preview.clear_view() self.cmp_original.clear() self.cmp_edited.clear() return try: img = ensure_srgb(Image.open(self._files[row])) self._current_img = img self._seg_mask = None self._seg_active = False self._mask_history.clear() self.seg_status.setText("") preview_img = img.copy() preview_img.thumbnail((1600, 1600), Image.Resampling.LANCZOS) adj = self._read_adj() chain = self._get_lut_chain_tuples() bands = self._read_hue_bands() preview_img = apply_adjustments(preview_img, adj, chain, bands) pix = self._pil_to_qpixmap(preview_img) self.preview.set_image(pix, img.width, img.height) if self.btn_compare.isChecked(): self._update_compare_view() except Exception: self._current_img = None self.preview.clear_view() self.cmp_original.clear() self.cmp_edited.clear() def _on_compare_toggled(self, checked: bool): self._compare_widget.setVisible(checked) self.preview.setVisible(not checked) if checked: self._update_compare_view() else: self.cmp_original.clear() self.cmp_edited.clear() def _update_compare_view(self): if self._current_img is None: return try: target = self.cmp_original.size() orig = self._current_img.copy() orig.thumbnail((1600, 1600), Image.Resampling.LANCZOS) orig_pix = self._pil_to_qpixmap(orig) self.cmp_original.setPixmap( orig_pix.scaled(target, Qt.KeepAspectRatio, Qt.SmoothTransformation) ) edited = self._current_img.copy() edited.thumbnail((1600, 1600), Image.Resampling.LANCZOS) adj = self._read_adj() chain = self._get_lut_chain_tuples() bands = self._read_hue_bands() post = self._read_post_adj() edited = apply_adjustments(edited, adj, chain, bands) rgb = pil_to_rgb_float01(edited) rgb = self._apply_seg_pipeline(rgb) rgb = apply_post_adj(rgb, post) edited = rgb_float01_to_pil(rgb) edited_pix = self._pil_to_qpixmap_rgba(edited) self.cmp_edited.setPixmap( edited_pix.scaled(target, Qt.KeepAspectRatio, Qt.SmoothTransformation) ) except Exception: pass def _on_settings_changed(self, *_): self._push_undo_snapshot() self._preview_timer.start() def _push_undo_snapshot(self): if not self._undo_recording: return state = self._current_state_data() if self._undo_stack and self._undo_stack[-1] == state: return self._undo_stack.append(state) if len(self._undo_stack) > 50: self._undo_stack.pop(0) self._redo_stack.clear() self._update_undo_buttons() def _on_undo(self): if len(self._undo_stack) < 2: return current = self._undo_stack.pop() self._redo_stack.append(current) previous = self._undo_stack[-1] self._undo_recording = False self._apply_state_data(previous) self._undo_recording = True self._update_undo_buttons() self._preview_timer.start() def _on_redo(self): if not self._redo_stack: return state = self._redo_stack.pop() self._undo_stack.append(state) self._undo_recording = False self._apply_state_data(state) self._undo_recording = True self._update_undo_buttons() self._preview_timer.start() def _update_undo_buttons(self): self.btn_undo.setEnabled(len(self._undo_stack) >= 2) self.btn_redo.setEnabled(len(self._redo_stack) > 0) def _update_processed_preview(self): if self._current_img is None: return try: img = self._current_img.copy() img.thumbnail((1600, 1600), Image.Resampling.LANCZOS) adj = self._read_adj() chain = self._get_lut_chain_tuples() bands = self._read_hue_bands() post = self._read_post_adj() img = apply_adjustments(img, adj, chain, bands) rgb = pil_to_rgb_float01(img) rgb = self._apply_seg_pipeline(rgb) rgb = apply_post_adj(rgb, post) img = rgb_float01_to_pil(rgb) pix = self._pil_to_qpixmap_rgba(img) if self.preview._pix_item is not None: self.preview.update_pixmap_only(pix) else: self.preview.set_image(pix, self._current_img.width, self._current_img.height) if self.btn_compare.isChecked(): self._update_compare_view() except Exception: pass def _apply_seg_pipeline(self, rgb: np.ndarray) -> np.ndarray: """Steps 4-7: segmentation → feather → compositing → retouch.""" if self._seg_active and self._seg_mask is not None: mask = self._seg_mask.copy() h, w = rgb.shape[:2] if mask.shape[:2] != (h, w): from PIL import Image as _Im mask_pil = _Im.fromarray((mask * 255).astype(np.uint8), mode="L") mask_pil = mask_pil.resize((w, h), _Im.Resampling.LANCZOS) mask = np.asarray(mask_pil).astype(np.float32) / 255.0 feather_r = self.sld_feather.value() if feather_r > 0: mask = feather_mask(mask, feather_r) bg_mode = self._get_bg_mode_str() result = composite_fg_bg( rgb, mask, bg_mode=bg_mode, bg_color=self._bg_color, bg_blur_radius=float(self.sld_bg_blur.value()), bg_image=self._bg_image, ) if result.shape[-1] == 4: rgb = result[..., :3] else: rgb = result skin = self.sld_skin_soft.value() if skin > 0: person_mask = self._seg_mask if self._seg_active and self._seg_mask is not None else None if person_mask is not None: h, w = rgb.shape[:2] if person_mask.shape[:2] != (h, w): from PIL import Image as _Im mp = _Im.fromarray((person_mask * 255).astype(np.uint8), mode="L") mp = mp.resize((w, h), _Im.Resampling.LANCZOS) person_mask = np.asarray(mp).astype(np.float32) / 255.0 rgb = soften_skin(rgb, person_mask, float(skin)) return rgb def _on_reset_all(self): self.sld_bright.reset() self.sld_contrast.reset() self.sld_sat.reset() self.sld_temp.reset() self.sld_tint.reset() self.sld_teal.reset() for sld in self._hue_sliders.values(): sld.reset() self.sld_post_bright.reset() self.sld_post_contrast.reset() self.sld_post_blacks.reset() self.sld_post_sharpen.reset() self.sld_feather.reset() self.sld_bg_blur.slider.setValue(25) self.sld_skin_soft.reset() self.combo_bg.setCurrentIndex(0) for entry in self._lut_chain: entry["strength"] = 50 self._rebuild_chain_list() self._preview_timer.start() def _on_reset_sliders(self): self.sld_bright.reset() self.sld_contrast.reset() self.sld_sat.reset() self.sld_temp.reset() self.sld_tint.reset() self.sld_teal.reset() self._preview_timer.start() def _on_reset_hue_bands(self): for sld in self._hue_sliders.values(): sld.reset() self._preview_timer.start() def _on_reset_post(self): self.sld_post_bright.reset() self.sld_post_contrast.reset() self.sld_post_blacks.reset() self.sld_post_sharpen.reset() self._preview_timer.start() # ── LUT chain ──────────────────────────────────────────────────────── def _rebuild_chain_list(self): self._rebuilding_chain = True self.lut_chain_list.clear() for i, entry in enumerate(self._lut_chain): item = QListWidgetItem() item.setSizeHint(QSize(0, 38)) item.setData(Qt.UserRole, i) self.lut_chain_list.addItem(item) self.lut_chain_list.setItemWidget( item, self._make_chain_item_widget(i, entry) ) self._rebuilding_chain = False if self._lut_chain: self.lut_chain_list.setCurrentRow(0) def _make_chain_item_widget(self, idx: int, entry: dict) -> QWidget: try: lut = get_cached_lut(entry["path"]) sz = str(lut.size) except Exception: sz = "?" w = QWidget() w.setStyleSheet("background: transparent;") lay = QHBoxLayout(w) lay.setContentsMargins(4, 1, 4, 1) lay.setSpacing(4) lbl = QLabel(f"{entry['name']} ({sz}\u00b3)") lbl.setStyleSheet("color:#ddd; font-size:11px;") lay.addWidget(lbl, 1) sld = QSlider(Qt.Horizontal) sld.setRange(0, 100) sld.setValue(entry["strength"]) sld.setFixedWidth(70) lay.addWidget(sld) val_lbl = QLabel(f"{entry['strength']}%") val_lbl.setFixedWidth(30) val_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) val_lbl.setStyleSheet("color:#aaa; font-size:11px;") lay.addWidget(val_lbl) sld.valueChanged.connect(lambda v, l=val_lbl: l.setText(f"{v}%")) sld.valueChanged.connect(lambda v, i=idx: self._on_inline_strength_changed(i, v)) return w def _on_inline_strength_changed(self, idx: int, val: int): if self._rebuilding_chain: return if 0 <= idx < len(self._lut_chain): self._lut_chain[idx]["strength"] = val self._preview_timer.start() def _on_chain_reordered(self): if self._rebuilding_chain: return old_chain = list(self._lut_chain) new_chain = [] for i in range(self.lut_chain_list.count()): orig_idx = self.lut_chain_list.item(i).data(Qt.UserRole) if isinstance(orig_idx, int) and 0 <= orig_idx < len(old_chain): new_chain.append(old_chain[orig_idx]) if len(new_chain) == len(old_chain): self._lut_chain = new_chain self._rebuild_chain_list() self._preview_timer.start() def _on_add_chain_lut(self): files, _ = QFileDialog.getOpenFileNames( self, "LUT(s) hinzuf\u00fcgen (.cube)", str(self._last_lut_dir), "Cube LUT (*.cube);;Alle (*.*)", ) if not files: return self._last_lut_dir = Path(files[0]).parent for fn in files: src = Path(fn) dest = LUT_DIR / src.name if not dest.exists(): shutil.copy2(src, dest) try: lut = get_cached_lut(dest) self._lut_chain.append({ "name": src.stem, "path": str(dest), "strength": 50, }) except (CubeParseError, Exception) as e: QMessageBox.critical(self, "LUT Fehler", str(e)) self._rebuild_chain_list() if self._lut_chain: self.lut_chain_list.setCurrentRow(len(self._lut_chain) - 1) self._preview_timer.start() def _on_remove_chain_lut(self): row = self.lut_chain_list.currentRow() if row < 0: return del self._lut_chain[row] self._rebuild_chain_list() self._preview_timer.start() def _on_chain_move_up(self): row = self.lut_chain_list.currentRow() if row <= 0: return self._lut_chain[row], self._lut_chain[row - 1] = ( self._lut_chain[row - 1], self._lut_chain[row], ) self._rebuild_chain_list() self.lut_chain_list.setCurrentRow(row - 1) self._preview_timer.start() def _on_chain_move_down(self): row = self.lut_chain_list.currentRow() if row < 0 or row >= len(self._lut_chain) - 1: return self._lut_chain[row], self._lut_chain[row + 1] = ( self._lut_chain[row + 1], self._lut_chain[row], ) self._rebuild_chain_list() self.lut_chain_list.setCurrentRow(row + 1) self._preview_timer.start() # ── KI segmentation ────────────────────────────────────────────────── def _on_segment(self): if self._current_img is None: return self.seg_status.setText("Bitte warten …") self.seg_progress.setVisible(True) self.btn_segment.setEnabled(False) QApplication.processEvents() img_small = self._current_img.copy() img_small.thumbnail((1024, 1024), Image.Resampling.LANCZOS) self._seg_thread = QThread(self) self._seg_worker = SegmentationWorker(img_small) self._seg_worker.moveToThread(self._seg_thread) self._seg_thread.started.connect(self._seg_worker.run) self._seg_worker.status.connect( self.seg_status.setText, Qt.QueuedConnection ) def on_error(err: str): self.seg_progress.setVisible(False) self.btn_segment.setEnabled(True) self.seg_status.setText("") QMessageBox.warning(self, "Segmentierung", f"Fehler:\n{err}") self._seg_thread.quit() def on_finished(mask_small): try: orig_w, orig_h = self._current_img.size ms_h, ms_w = mask_small.shape[:2] if (ms_w, ms_h) != (orig_w, orig_h): mask_pil = Image.fromarray( (np.clip(mask_small, 0.0, 1.0) * 255).astype(np.uint8), mode="L", ) mask_pil = mask_pil.resize( (orig_w, orig_h), Image.Resampling.LANCZOS ) mask = np.asarray(mask_pil).astype(np.float32) / 255.0 else: mask = mask_small.astype(np.float32) self._seg_mask = np.clip(mask, 0.0, 1.0).astype(np.float32) self._seg_active = True self._mask_history.clear() finally: self._seg_thread.quit() QTimer.singleShot(0, self._on_seg_finished_ui) self._seg_worker.error.connect(on_error, Qt.QueuedConnection) self._seg_worker.finished.connect(on_finished, Qt.QueuedConnection) self._seg_thread.finished.connect(self._seg_worker.deleteLater) self._seg_thread.finished.connect(self._seg_thread.deleteLater) self._seg_thread.start() def _on_seg_finished_ui(self): """Called on main thread after segmentation completes.""" self.seg_progress.setVisible(False) self.seg_status.setText("Freistellung aktiv") self.btn_segment.setEnabled(True) self._preview_timer.start() def _on_seg_clear(self): self._seg_mask = None self._seg_active = False self._mask_history.clear() self.seg_status.setText("") self._preview_timer.start() def _on_bg_mode_changed(self, idx: int): self.sld_bg_blur.setVisible(idx == 1) self.btn_bg_color.setVisible(idx == 2) self.lbl_bg_color.setVisible(idx == 2) self.btn_bg_image.setVisible(idx == 4) self._update_bg_color_swatch() self._preview_timer.start() def _on_pick_bg_color(self): c = QColorDialog.getColor( QColor(*self._bg_color), self, "Hintergrundfarbe" ) if c.isValid(): self._bg_color = (c.red(), c.green(), c.blue()) self._update_bg_color_swatch() self._preview_timer.start() def _update_bg_color_swatch(self): r, g, b = self._bg_color self.lbl_bg_color.setStyleSheet( f"background:rgb({r},{g},{b}); border:1px solid #555; border-radius:4px;" ) def _on_load_bg_image(self): f, _ = QFileDialog.getOpenFileName( self, "Hintergrundbild", str(self._last_image_dir), "Bilder (*.png *.jpg *.jpeg *.bmp *.tif *.tiff *.webp);;Alle (*.*)", ) if f: try: img = ensure_srgb(Image.open(f)) self._bg_image = pil_to_rgb_float01(img) self._preview_timer.start() except Exception as e: QMessageBox.warning(self, "Fehler", str(e)) def _get_bg_mode_str(self) -> str: idx = self.combo_bg.currentIndex() return ["original", "blur", "color", "transparent", "image"][idx] # ── Brush / mask editing ───────────────────────────────────────────── def _on_brush_toggled(self, active: bool): self._brush_mode = active self.preview.set_brush_mode(active) def _on_mask_undo(self): prev = self._mask_history.undo() if prev is not None: self._seg_mask = prev self._preview_timer.start() def _on_brush_painted(self, points: list): if self._seg_mask is None or not points: return self._mask_history.push(self._seg_mask) radius = self.sld_brush_size.value() hardness = self.sld_brush_hard.value() / 100.0 mask_h, mask_w = self._seg_mask.shape[:2] if self._current_img: orig_w, orig_h = self._current_img.size preview_img = self._current_img.copy() preview_img.thumbnail((1600, 1600), Image.Resampling.LANCZOS) pw, ph = preview_img.size sx = mask_w / pw if pw > 0 else 1.0 sy = mask_h / ph if ph > 0 else 1.0 scaled = [(int(x * sx), int(y * sy)) for x, y in points] r_scaled = max(1, int(radius * sx)) else: scaled = points r_scaled = radius self._seg_mask = apply_brush_stroke( self._seg_mask, scaled, r_scaled, hardness, self._brush_add ) self._preview_timer.start() # ── Looks ──────────────────────────────────────────────────────────── def _refresh_looks_list(self): self.looks_list.clear() for name in sorted(self._looks.keys()): self.looks_list.addItem(name) def _on_add_look(self): name, ok = QInputDialog.getText( self, "Look speichern", "Name f\u00fcr den neuen Look:", QLineEdit.Normal, "", ) if not ok or not name.strip(): return self._looks[name.strip()] = self._current_state_data() self._save_config() self._refresh_looks_list() def _on_remove_look(self): row = self.looks_list.currentRow() if row < 0: return name = self.looks_list.item(row).text() reply = QMessageBox.question( self, "Look entfernen?", f'Look "{name}" entfernen?', QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply == QMessageBox.Yes: self._looks.pop(name, None) self._save_config() self._refresh_looks_list() def _on_look_selected(self, item): name = item.text() data = self._looks.get(name) if not data: return reply = QMessageBox.question( self, f'Look "{name}"', f'Was möchten Sie mit dem Look "{name}" tun?', QMessageBox.Open | QMessageBox.Save | QMessageBox.Cancel, QMessageBox.Open, ) if reply == QMessageBox.Open: self._apply_state_data(data, warn_missing_luts=True) self._preview_timer.start() elif reply == QMessageBox.Save: self._looks[name] = self._current_state_data() self._save_config() QMessageBox.information(self, "Look aktualisiert", f'Look "{name}" wurde mit den aktuellen Einstellungen überschrieben.') # ── Directory ──────────────────────────────────────────────────────── def _on_choose_dir(self): d = QFileDialog.getExistingDirectory(self, "Zielordner", str(self._out_dir)) if d: self._out_dir = Path(d) self.lbl_dir.setText(str(self._out_dir)) # ── Save ───────────────────────────────────────────────────────────── def _on_save_current_new(self): if self._current_img is None: return src = self._files[self._current_idx] name, ok = QInputDialog.getText( self, "Dateiname", "Name f\u00fcr das neue Foto (ohne Endung):", QLineEdit.Normal, src.stem, ) if not ok or not name.strip(): return try: self._out_dir.mkdir(parents=True, exist_ok=True) result = self._process_image(self._current_img.copy()) out_path = self._out_dir / (name.strip() + self._get_ext()) self._save_image(result, out_path) QMessageBox.information(self, "Gespeichert", f"Gespeichert:\n{out_path}") except Exception as e: QMessageBox.critical(self, "Fehler", str(e)) def _on_overwrite_current(self): if self._current_img is None: return src = self._files[self._current_idx] reply = QMessageBox.question( self, "\u00dcberschreiben?", f"Original \u00fcberschreiben?\n{src.name}\n\nKann nicht r\u00fcckg\u00e4ngig gemacht werden!", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply != QMessageBox.Yes: return try: result = self._process_image(self._current_img.copy()) self._save_image(result, src) QMessageBox.information(self, "Gespeichert", f"\u00dcberschrieben:\n{src}") except Exception as e: QMessageBox.critical(self, "Fehler", str(e)) def _on_batch_save(self): if not self._files: return try: self._out_dir.mkdir(parents=True, exist_ok=True) except Exception as e: QMessageBox.critical(self, "Fehler", f"Zielordner ung\u00fcltig:\n{e}") return self.progress.setVisible(True) self.progress.setRange(0, len(self._files)) self.progress.setValue(0) self.btn_batch.setEnabled(False) QApplication.processEvents() errors = [] saved = 0 ext = self._get_ext() for i, fpath in enumerate(self._files): try: result = self._process_image_for_path(fpath) out_path = self._out_dir / (fpath.stem + ext) self._save_image(result, out_path) saved += 1 except Exception as e: errors.append(f"{fpath.name}: {e}") self.progress.setValue(i + 1) QApplication.processEvents() self.progress.setVisible(False) self.btn_batch.setEnabled(True) msg = f"{saved}/{len(self._files)} Fotos gespeichert.\nOrdner: {self._out_dir}" if errors: msg += f"\n\nFehler ({len(errors)}):\n" + "\n".join(errors[:5]) QMessageBox.information(self, "Batch fertig", msg)