Files
nl-tools/ui/image_preview.py

380 lines
14 KiB
Python
Raw Permalink Normal View History

2025-12-11 09:47:00 +08:00
"""
图片预览组件
- 原图/处理后对比预览
- 缩放/平移
- 信息显示
"""
import os
from pathlib import Path
from PIL import Image
import io
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QScrollArea, QSplitter, QFileDialog, QMessageBox
)
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QPixmap, QImage, QFont
from core.config import config
class ImagePreviewWidget(QFrame):
"""单个图片预览组件"""
def __init__(self, title: str = "预览", parent=None):
super().__init__(parent)
self.title = title
self._pixmap = None
self._image_info = {}
self.setup_ui()
def setup_ui(self):
self.setStyleSheet("""
ImagePreviewWidget {
background: rgba(15, 23, 42, 0.5);
border: 1px solid #334155;
border-radius: 12px;
}
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# 标题栏
header = QHBoxLayout()
self.title_label = QLabel(self.title)
self.title_label.setStyleSheet("""
background: rgba(0, 0, 0, 0.5);
color: white;
font-size: 11px;
padding: 4px 10px;
border-radius: 4px;
""")
header.addWidget(self.title_label)
header.addStretch()
# 尺寸信息
self.size_label = QLabel("")
self.size_label.setStyleSheet("color: #64748b; font-size: 10px;")
header.addWidget(self.size_label)
layout.addLayout(header)
# 图片显示区域
self.image_container = QFrame()
self.image_container.setStyleSheet("""
background: #0f172a;
border-radius: 8px;
""")
self.image_container.setMinimumHeight(250)
container_layout = QVBoxLayout(self.image_container)
container_layout.setContentsMargins(8, 8, 8, 8)
container_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# 占位图标
self.placeholder_icon = QLabel("🖼️")
self.placeholder_icon.setFont(QFont("Segoe UI Emoji", 40))
self.placeholder_icon.setStyleSheet("color: #334155;")
self.placeholder_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
container_layout.addWidget(self.placeholder_icon)
# 占位文字
self.placeholder_text = QLabel("暂无图片")
self.placeholder_text.setStyleSheet("color: #64748b; font-size: 12px;")
self.placeholder_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
container_layout.addWidget(self.placeholder_text)
# 图片标签
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setVisible(False)
container_layout.addWidget(self.image_label)
layout.addWidget(self.image_container, 1)
# 文件信息
self.info_label = QLabel("")
self.info_label.setStyleSheet("color: #94a3b8; font-size: 11px;")
self.info_label.setWordWrap(True)
layout.addWidget(self.info_label)
def set_image(self, pixmap: QPixmap = None, file_path: str = None,
pil_image: Image.Image = None, image_bytes: bytes = None):
"""设置预览图片"""
if pixmap:
self._pixmap = pixmap
elif file_path and os.path.exists(file_path):
self._pixmap = QPixmap(file_path)
self._image_info = self._get_file_info(file_path)
elif pil_image:
self._pixmap = self._pil_to_pixmap(pil_image)
elif image_bytes:
self._pixmap = QPixmap()
self._pixmap.loadFromData(image_bytes)
else:
self._pixmap = None
self._update_display()
def set_image_from_bytes(self, data: bytes, info: dict = None):
"""从字节数据设置图片"""
self._pixmap = QPixmap()
self._pixmap.loadFromData(data)
if info:
self._image_info = info
self._update_display()
def _pil_to_pixmap(self, pil_image: Image.Image) -> QPixmap:
"""PIL Image转QPixmap"""
if pil_image.mode == "RGB":
data = pil_image.tobytes("raw", "RGB")
qimage = QImage(data, pil_image.width, pil_image.height,
pil_image.width * 3, QImage.Format.Format_RGB888)
elif pil_image.mode == "RGBA":
data = pil_image.tobytes("raw", "RGBA")
qimage = QImage(data, pil_image.width, pil_image.height,
pil_image.width * 4, QImage.Format.Format_RGBA8888)
else:
pil_image = pil_image.convert("RGB")
data = pil_image.tobytes("raw", "RGB")
qimage = QImage(data, pil_image.width, pil_image.height,
pil_image.width * 3, QImage.Format.Format_RGB888)
return QPixmap.fromImage(qimage)
def _get_file_info(self, file_path: str) -> dict:
"""获取文件信息"""
try:
size = os.path.getsize(file_path)
with Image.open(file_path) as img:
return {
"path": file_path,
"name": Path(file_path).name,
"width": img.width,
"height": img.height,
"size": size,
"format": img.format
}
except:
return {}
def _update_display(self):
"""更新显示"""
if self._pixmap and not self._pixmap.isNull():
# 隐藏占位符
self.placeholder_icon.setVisible(False)
self.placeholder_text.setVisible(False)
self.image_label.setVisible(True)
# 缩放图片适应容器
container_size = self.image_container.size()
max_width = container_size.width() - 20
max_height = container_size.height() - 20
scaled = self._pixmap.scaled(
QSize(max_width, max_height),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.image_label.setPixmap(scaled)
# 更新尺寸信息
self.size_label.setText(f"{self._pixmap.width()}×{self._pixmap.height()}")
# 更新文件信息
if self._image_info:
size_str = self._format_size(self._image_info.get("size", 0))
self.info_label.setText(
f"📁 {self._image_info.get('name', '')} | 💾 {size_str}"
)
else:
# 显示占位符
self.placeholder_icon.setVisible(True)
self.placeholder_text.setVisible(True)
self.image_label.setVisible(False)
self.size_label.setText("")
self.info_label.setText("")
def set_info(self, info: dict):
"""设置图片信息"""
self._image_info = info
if info:
size_str = self._format_size(info.get("size", 0))
self.info_label.setText(
f"📁 {info.get('name', '')} | 💾 {size_str}"
)
def clear(self):
"""清空预览"""
self._pixmap = None
self._image_info = {}
self._update_display()
def resizeEvent(self, event):
"""窗口大小变化时重新缩放图片"""
super().resizeEvent(event)
if self._pixmap and not self._pixmap.isNull():
self._update_display()
@staticmethod
def _format_size(size: int) -> str:
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
class DualPreviewWidget(QWidget):
"""双栏预览组件 - 原图/处理后对比"""
save_requested = Signal(object) # 发出保存请求信号,携带处理后的数据
def __init__(self, parent=None):
super().__init__(parent)
self._processed_data = None # 保存处理后的数据
self._output_filename = ""
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
# 预览区 - 左右分栏
preview_layout = QHBoxLayout()
preview_layout.setSpacing(16)
# 原图预览
self.original_preview = ImagePreviewWidget("📷 原图")
preview_layout.addWidget(self.original_preview, 1)
# 处理后预览
self.result_preview = ImagePreviewWidget("✨ 处理结果")
preview_layout.addWidget(self.result_preview, 1)
layout.addLayout(preview_layout, 1)
# 对比信息
self.compare_label = QLabel("")
self.compare_label.setStyleSheet("""
color: #22c55e;
font-size: 13px;
padding: 8px;
background: rgba(34, 197, 94, 0.1);
border-radius: 8px;
""")
self.compare_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.compare_label.setVisible(False)
layout.addWidget(self.compare_label)
# 操作按钮
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.save_btn = QPushButton("💾 保存结果")
self.save_btn.setObjectName("primary_btn")
self.save_btn.setMinimumSize(150, 40)
self.save_btn.clicked.connect(self._on_save_clicked)
self.save_btn.setEnabled(False)
btn_layout.addWidget(self.save_btn)
layout.addLayout(btn_layout)
def set_original(self, file_path: str):
"""设置原图"""
self.original_preview.set_image(file_path=file_path)
def set_result(self, data: bytes, info: dict = None, filename: str = "",
show_size_compare: bool = True):
"""设置处理结果"""
self._processed_data = data
self._output_filename = filename
self.result_preview.set_image_from_bytes(data, info)
self.save_btn.setEnabled(True)
# 显示对比信息(仅在压缩场景下显示大小对比)
if info and show_size_compare:
original_size = self.original_preview._image_info.get("size", 0)
result_size = info.get("size", len(data))
if original_size > 0:
saved = original_size - result_size
percent = (saved / original_size) * 100
if saved > 0:
self.compare_label.setText(
f"✅ 处理完成! 节省 {self._format_size(saved)} ({percent:.1f}%)"
)
self.compare_label.setStyleSheet("""
color: #22c55e;
font-size: 13px;
padding: 8px;
background: rgba(34, 197, 94, 0.1);
border-radius: 8px;
""")
self.compare_label.setVisible(True)
else:
# 文件变大或相同,不显示对比(可能是添加水印等操作)
self.compare_label.setVisible(False)
elif not show_size_compare:
# 不需要显示大小对比(如水印、格式转换)
self.compare_label.setText("✅ 处理完成!")
self.compare_label.setStyleSheet("""
color: #22c55e;
font-size: 13px;
padding: 8px;
background: rgba(34, 197, 94, 0.1);
border-radius: 8px;
""")
self.compare_label.setVisible(True)
def _on_save_clicked(self):
"""保存按钮点击"""
if not self._processed_data:
return
# 检查是否有默认保存路径
default_dir = config.get_output_directory()
auto_save = config.get("auto_save_to_default", False)
if default_dir and auto_save:
# 自动保存
save_path = os.path.join(default_dir, self._output_filename)
else:
# 询问保存位置
save_path, _ = QFileDialog.getSaveFileName(
self, "保存文件",
os.path.join(default_dir, self._output_filename) if default_dir else self._output_filename,
"图片文件 (*.jpg *.png *.webp)"
)
if save_path:
try:
with open(save_path, 'wb') as f:
f.write(self._processed_data)
QMessageBox.information(self, "成功", f"文件已保存到:\n{save_path}")
self.save_requested.emit(save_path)
except Exception as e:
QMessageBox.critical(self, "错误", f"保存失败:\n{e}")
def clear(self):
"""清空预览"""
self.original_preview.clear()
self.result_preview.clear()
self._processed_data = None
self._output_filename = ""
self.save_btn.setEnabled(False)
self.compare_label.setVisible(False)
@staticmethod
def _format_size(size: int) -> str:
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"