2392 lines
90 KiB
Python
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)
|