Files
photostation/src/ui/screens/editor_screen.py
Jules 980a80d43a Warning ratio sur galerie + cadre recadrage auto dans l'éditeur
Galerie :
- Petit badge orange ⚠ en haut à droite des photos pas au ratio 3:2
- Discret, tooltip "Photo à recadrer"
- Tolérance 5% autour du ratio 1.5

Éditeur :
- Si la photo n'est pas au ratio 3:2, le cadre de recadrage
  s'affiche automatiquement en suggestion à l'ouverture
- L'utilisateur peut le déplacer/redimensionner ou le valider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:36:12 +01:00

443 lines
16 KiB
Python

"""Écran éditeur — recadrage, goodies, effets, multi-pose, texte."""
import os
import tempfile
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem,
QGraphicsTextItem, QFileDialog, QInputDialog
)
from PyQt6.QtCore import Qt, QRectF
from PyQt6.QtGui import (
QFont, QPixmap, QImage, QPainter, QColor, QTransform
)
from ui.widgets.crop_overlay import CropOverlay
from ui.widgets.goodies_panel import GoodiesPanel
from ui.widgets.multipose_dialog import MultiposeDialog, TEMPLATES
class EditorScreen(QWidget):
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self.current_image_path = None
self.pixmap_item = None
self.crop_overlay = None
self.is_cropping = False
self.multipose_zones = None
self.multipose_images = {}
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(5)
layout.setContentsMargins(5, 5, 5, 5)
# Header
header = QHBoxLayout()
back_btn = QPushButton("Retour")
back_btn.setFont(QFont("", 14))
back_btn.clicked.connect(self._go_back)
header.addWidget(back_btn)
title = QLabel("Éditeur")
title.setFont(QFont("", 24, QFont.Weight.Bold))
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
header.addWidget(title, 1)
print_btn = QPushButton("Imprimer")
print_btn.setFont(QFont("", 14))
print_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50; color: white;
border-radius: 10px; padding: 10px 20px;
}
QPushButton:pressed { background-color: #388E3C; }
""")
print_btn.clicked.connect(self._go_print)
header.addWidget(print_btn)
layout.addLayout(header)
# Zone centrale : goodies panel + canvas
center = QHBoxLayout()
# Panneau goodies (caché par défaut)
self.goodies_panel = GoodiesPanel()
self.goodies_panel.sticker_selected.connect(self._place_sticker)
self.goodies_panel.frame_selected.connect(self._apply_frame)
self.goodies_panel.hide()
center.addWidget(self.goodies_panel)
# Canvas
self.scene = QGraphicsScene()
self.view = QGraphicsView(self.scene)
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
self.view.setStyleSheet("background-color: #333; border: none;")
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
center.addWidget(self.view, 1)
layout.addLayout(center, 1)
# Barre d'outils
tools = QHBoxLayout()
tools.setSpacing(8)
tool_buttons = [
("Recadrer", self._toggle_crop),
("Rotation", self._rotate),
("N&B", self._filter_grayscale),
("Sépia", self._filter_sepia),
("Vintage", self._filter_vintage),
("Contraste", self._filter_contrast),
("Goodies", self._toggle_goodies),
("Multi-pose", self._multipose),
("Texte", self._add_text),
("Annuler", self._reset_image),
]
for label, callback in tool_buttons:
btn = QPushButton(label)
btn.setFont(QFont("", 12))
btn.setMinimumHeight(50)
btn.setStyleSheet("""
QPushButton {
background-color: #555; color: white;
border-radius: 8px; padding: 8px 12px;
}
QPushButton:pressed { background-color: #777; }
""")
btn.clicked.connect(callback)
tools.addWidget(btn)
layout.addLayout(tools)
def load_image(self, path):
self.current_image_path = path
self.multipose_zones = None
self.multipose_images = {}
self._cancel_crop()
self.scene.clear()
pixmap = QPixmap(path)
self.pixmap_item = self.scene.addPixmap(pixmap)
self.scene.setSceneRect(QRectF(pixmap.rect()))
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
# Auto-afficher le cadre de recadrage si ratio ≠ 3:2
w, h = pixmap.width(), pixmap.height()
if w > 0 and h > 0:
ratio = max(w, h) / min(w, h)
if abs(ratio - 1.5) > 0.075:
self._show_ratio_crop()
def resizeEvent(self, event):
super().resizeEvent(event)
if self.pixmap_item:
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
# --- Export ---
def _export_scene(self):
"""Exporte la scène complète en QImage haute résolution."""
rect = self.scene.sceneRect()
image = QImage(int(rect.width()), int(rect.height()), QImage.Format.Format_ARGB32)
image.fill(QColor(255, 255, 255))
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
self.scene.render(painter)
painter.end()
return image
def _export_to_file(self):
"""Exporte la scène dans un fichier temporaire et retourne le chemin."""
image = self._export_scene()
path = os.path.join(tempfile.gettempdir(), "photostation_export.png")
image.save(path, "PNG")
return path
# --- Cadre ratio suggéré ---
def _show_ratio_crop(self):
"""Affiche un cadre de recadrage au ratio 3:2 en suggestion."""
if not self.pixmap_item:
return
self.is_cropping = True
self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
pr = self.pixmap_item.boundingRect()
img_w, img_h = pr.width(), pr.height()
ratio = 1.5
# Adapter au sens de l'image
if img_w < img_h:
ratio = 1.0 / ratio
# Plus grande zone possible
if img_w / img_h > ratio:
crop_h = img_h * 0.95
crop_w = crop_h * ratio
else:
crop_w = img_w * 0.95
crop_h = crop_w / ratio
x = (img_w - crop_w) / 2
y = (img_h - crop_h) / 2
crop_rect = QRectF(x, y, crop_w, crop_h)
self.crop_overlay = CropOverlay(crop_rect)
self.crop_overlay.set_bounds(pr)
self.scene.addItem(self.crop_overlay)
# --- Navigation ---
def _go_back(self):
self._cancel_crop()
self.main_window.show_import()
def _go_print(self):
path = self._export_to_file()
self.main_window.show_print(path)
# --- Recadrage ---
def _toggle_crop(self):
if self.is_cropping:
self._apply_crop()
else:
self._start_crop()
def _start_crop(self):
if not self.pixmap_item:
return
self.is_cropping = True
self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
pr = self.pixmap_item.boundingRect()
# Crop initial = 80% centré
margin_x = pr.width() * 0.1
margin_y = pr.height() * 0.1
crop_rect = QRectF(margin_x, margin_y,
pr.width() - 2 * margin_x, pr.height() - 2 * margin_y)
self.crop_overlay = CropOverlay(crop_rect)
self.crop_overlay.set_bounds(pr)
self.scene.addItem(self.crop_overlay)
def _apply_crop(self):
if not self.crop_overlay or not self.pixmap_item:
return
crop_rect = self.crop_overlay.get_crop_rect()
pixmap = self.pixmap_item.pixmap()
cropped = pixmap.copy(crop_rect.toRect())
self.scene.removeItem(self.crop_overlay)
self.crop_overlay = None
self.is_cropping = False
self.scene.clear()
self.pixmap_item = self.scene.addPixmap(cropped)
self.scene.setSceneRect(QRectF(cropped.rect()))
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
def _cancel_crop(self):
if self.crop_overlay:
self.scene.removeItem(self.crop_overlay)
self.crop_overlay = None
self.is_cropping = False
# --- Rotation ---
def _rotate(self):
if not self.pixmap_item:
return
pixmap = self.pixmap_item.pixmap()
transform = QTransform().rotate(90)
rotated = pixmap.transformed(transform, Qt.TransformationMode.SmoothTransformation)
self.scene.clear()
self.pixmap_item = self.scene.addPixmap(rotated)
self.scene.setSceneRect(QRectF(rotated.rect()))
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
# --- Filtres ---
def _apply_pil_filter(self, pil_func):
"""Applique un filtre Pillow sur l'image actuelle du canvas."""
if not self.pixmap_item:
return
from PIL import Image
# QPixmap -> PIL
pixmap = self.pixmap_item.pixmap()
qimg = pixmap.toImage().convertToFormat(QImage.Format.Format_RGBA8888)
width, height = qimg.width(), qimg.height()
ptr = qimg.bits()
ptr.setsize(width * height * 4)
pil_img = Image.frombytes("RGBA", (width, height), bytes(ptr))
# Appliquer le filtre
result = pil_func(pil_img)
# PIL -> QPixmap
data = result.convert("RGBA").tobytes("raw", "RGBA")
new_qimg = QImage(data, result.width, result.height, QImage.Format.Format_RGBA8888)
new_pixmap = QPixmap.fromImage(new_qimg)
self.scene.clear()
self.pixmap_item = self.scene.addPixmap(new_pixmap)
self.scene.setSceneRect(QRectF(new_pixmap.rect()))
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
def _filter_grayscale(self):
from PIL import ImageOps
self._apply_pil_filter(lambda img: ImageOps.grayscale(img).convert("RGBA"))
def _filter_sepia(self):
from PIL import ImageOps
def sepia(img):
gray = ImageOps.grayscale(img)
return ImageOps.colorize(gray, "#704214", "#C0A080").convert("RGBA")
self._apply_pil_filter(sepia)
def _filter_vintage(self):
from PIL import ImageOps, ImageEnhance
def vintage(img):
rgb = img.convert("RGB")
rgb = ImageEnhance.Color(rgb).enhance(0.6)
rgb = ImageEnhance.Contrast(rgb).enhance(1.2)
rgb = ImageEnhance.Brightness(rgb).enhance(0.9)
return rgb.convert("RGBA")
self._apply_pil_filter(vintage)
def _filter_contrast(self):
from PIL import ImageEnhance
def contrast(img):
rgb = img.convert("RGB")
rgb = ImageEnhance.Contrast(rgb).enhance(1.5)
return rgb.convert("RGBA")
self._apply_pil_filter(contrast)
# --- Goodies ---
def _toggle_goodies(self):
self.goodies_panel.setVisible(not self.goodies_panel.isVisible())
def _place_sticker(self, path):
"""Ajoute un sticker draggable sur le canvas."""
pixmap = QPixmap(path)
if pixmap.isNull():
return
# Taille sticker = 20% de la largeur de la scène
scene_w = self.scene.sceneRect().width()
if scene_w > 0:
target_w = int(scene_w * 0.2)
pixmap = pixmap.scaledToWidth(target_w, Qt.TransformationMode.SmoothTransformation)
item = self.scene.addPixmap(pixmap)
item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsMovable, True)
item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsSelectable, True)
# Centrer
sr = self.scene.sceneRect()
item.setPos(sr.center().x() - pixmap.width() / 2,
sr.center().y() - pixmap.height() / 2)
def _apply_frame(self, path):
"""Applique un cadre par-dessus l'image (en overlay)."""
if not self.pixmap_item:
return
frame_pixmap = QPixmap(path)
if frame_pixmap.isNull():
return
sr = self.scene.sceneRect()
scaled = frame_pixmap.scaled(
int(sr.width()), int(sr.height()),
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
frame_item = self.scene.addPixmap(scaled)
frame_item.setPos(sr.topLeft())
frame_item.setZValue(100)
# --- Multi-pose ---
def _multipose(self):
name, zones = MultiposeDialog.get_template(self)
if not zones:
return
# Utilise la photo en cours (ou l'image éditée du canvas)
source_pixmap = None
if self.pixmap_item:
source_pixmap = self.pixmap_item.pixmap()
elif self.current_image_path:
source_pixmap = QPixmap(self.current_image_path)
if not source_pixmap or source_pixmap.isNull():
return
self.multipose_zones = zones
self.multipose_source = source_pixmap
# Taille canvas = format d'impression (ex: 1800x1200 pour 15x10)
canvas_w = 1800
canvas_h = 1200
self._render_multipose(source_pixmap, zones, canvas_w, canvas_h)
def _crop_to_fill(self, pixmap, target_w, target_h):
"""Crop centré pour remplir exactement target_w x target_h."""
src_w = pixmap.width()
src_h = pixmap.height()
src_ratio = src_w / src_h
target_ratio = target_w / target_h
if src_ratio > target_ratio:
# Image trop large → crop horizontal
new_w = int(src_h * target_ratio)
x_offset = (src_w - new_w) // 2
cropped = pixmap.copy(x_offset, 0, new_w, src_h)
else:
# Image trop haute → crop vertical
new_h = int(src_w / target_ratio)
y_offset = (src_h - new_h) // 2
cropped = pixmap.copy(0, y_offset, src_w, new_h)
return cropped.scaled(target_w, target_h,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation)
def _render_multipose(self, source_pixmap, zones, canvas_w, canvas_h):
"""Dessine le multi-pose : même image dupliquée, crop to fill."""
self.scene.clear()
self.pixmap_item = None
# Canvas blanc
white = QImage(canvas_w, canvas_h, QImage.Format.Format_ARGB32)
white.fill(QColor(255, 255, 255))
self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(white))
self.scene.setSceneRect(QRectF(0, 0, canvas_w, canvas_h))
gap = 6 # espace entre les zones
for i, (zx, zy, zw, zh) in enumerate(zones):
rx = int(zx * canvas_w) + gap
ry = int(zy * canvas_h) + gap
rw = int(zw * canvas_w) - gap * 2
rh = int(zh * canvas_h) - gap * 2
if rw <= 0 or rh <= 0:
continue
# Crop to fill : l'image remplit toute la zone
filled = self._crop_to_fill(source_pixmap, rw, rh)
item = self.scene.addPixmap(filled)
item.setPos(rx, ry)
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
# --- Texte ---
def _add_text(self):
text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :")
if not ok or not text:
return
item = QGraphicsTextItem(text)
item.setFont(QFont("", 32))
item.setDefaultTextColor(QColor(255, 255, 255))
item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsMovable, True)
item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True)
item.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction)
sr = self.scene.sceneRect()
item.setPos(sr.center().x() - 100, sr.center().y() - 20)
self.scene.addItem(item)
# --- Reset ---
def _reset_image(self):
if self.current_image_path:
self.load_image(self.current_image_path)