Files
aza/APP/fotoapp - Kopie/ui.py
2026-03-25 14:14:07 +01:00

2392 lines
90 KiB
Python

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)