""" 图片压缩工具 - 极致优化版 专注于:在保证视觉效果不降低的情况下,极致压缩文件大小 - 保持原有格式(不转换格式) - 多种压缩模式 - 智能参数优化 """ import os import io import logging from pathlib import Path from PIL import Image from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QFrame, QFileDialog, QMessageBox, QProgressBar, QListWidget, QListWidgetItem, QCheckBox, QGroupBox, QRadioButton, QButtonGroup, QComboBox ) from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QFont from ui.workspace import BaseWorkspace, UploadArea from ui.image_preview import DualPreviewWidget from core.config import config class SmartCompressor: """智能图片压缩器 - 保持原格式,极致压缩""" # 压缩模式 MODE_VISUALLY_LOSSLESS = "visually" # 视觉无损(推荐) MODE_BALANCED = "balanced" # 均衡模式 MODE_MAXIMUM = "maximum" # 极致压缩 MODE_LOSSLESS = "lossless" # 完全无损 @classmethod def compress(cls, img: Image.Image, original_format: str, mode: str, quality_override: int = None) -> tuple: """ 压缩图片(保持原格式) Args: img: PIL Image对象 original_format: 原始格式 (jpeg/png/webp) mode: 压缩模式 quality_override: 手动覆盖质量值 Returns: (compressed_data, output_extension) """ # 标准化格式名 fmt = original_format.lower() if fmt in ['jpg', 'jpeg']: return cls._compress_jpeg(img, mode, quality_override) elif fmt == 'png': return cls._compress_png(img, mode) elif fmt == 'webp': return cls._compress_webp(img, mode, quality_override) elif fmt == 'gif': return cls._compress_gif(img) else: # 未知格式,转为JPEG压缩 if img.mode in ('RGBA', 'LA', 'P'): background = Image.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') if img.mode in ('RGBA', 'LA'): background.paste(img, mask=img.split()[-1]) else: background.paste(img) img = background elif img.mode != 'RGB': img = img.convert('RGB') return cls._compress_jpeg(img, mode, quality_override) @classmethod def _compress_jpeg(cls, img: Image.Image, mode: str, quality_override: int = None) -> tuple: """JPEG极致压缩""" # 确保是RGB模式 if img.mode in ('RGBA', 'LA', 'P'): background = Image.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') if img.mode in ('RGBA', 'LA'): background.paste(img, mask=img.split()[-1]) else: background.paste(img) img = background elif img.mode != 'RGB': img = img.convert('RGB') buffer = io.BytesIO() # 根据模式选择参数 if quality_override is not None: quality = quality_override else: quality = { cls.MODE_LOSSLESS: 100, cls.MODE_VISUALLY_LOSSLESS: 88, # 视觉无损的最佳质量 cls.MODE_BALANCED: 80, cls.MODE_MAXIMUM: 70, }.get(mode, 85) # 子采样设置:quality高时用4:4:4保持质量 if quality >= 90: subsampling = 0 # 4:4:4 elif quality >= 80: subsampling = 1 # 4:2:2 else: subsampling = 2 # 4:2:0 img.save( buffer, "JPEG", quality=quality, optimize=True, subsampling=subsampling, progressive=True ) return buffer.getvalue(), ".jpg" @classmethod def _compress_png(cls, img: Image.Image, mode: str) -> tuple: """PNG压缩(无损,但优化)""" buffer = io.BytesIO() # PNG是无损格式,只能通过优化来减小 # 对于极致压缩模式,尝试减少颜色 if mode == cls.MODE_MAXIMUM: # 检查是否可以用调色板模式 if img.mode == 'RGBA': colors = img.getcolors(maxcolors=256) if colors: img = img.convert('P', palette=Image.Palette.ADAPTIVE, colors=len(colors)) elif img.mode == 'RGB': colors = img.getcolors(maxcolors=256) if colors: img = img.convert('P', palette=Image.Palette.ADAPTIVE, colors=len(colors)) img.save( buffer, "PNG", optimize=True, compress_level=9 # 最大压缩级别 ) return buffer.getvalue(), ".png" @classmethod def _compress_webp(cls, img: Image.Image, mode: str, quality_override: int = None) -> tuple: """WebP压缩""" buffer = io.BytesIO() if mode == cls.MODE_LOSSLESS: img.save(buffer, "WEBP", lossless=True, quality=100) else: if quality_override is not None: quality = quality_override else: quality = { cls.MODE_VISUALLY_LOSSLESS: 88, cls.MODE_BALANCED: 80, cls.MODE_MAXIMUM: 70, }.get(mode, 85) img.save( buffer, "WEBP", quality=quality, method=6 # 最慢但压缩率最高 ) return buffer.getvalue(), ".webp" @classmethod def _compress_gif(cls, img: Image.Image) -> tuple: """GIF保持原样(GIF压缩会丢失动画)""" buffer = io.BytesIO() img.save(buffer, "GIF", optimize=True) return buffer.getvalue(), ".gif" class CompressWorker(QThread): """压缩工作线程""" progress = Signal(int, int) file_processed = Signal(str, bytes, dict) finished = Signal(list) def __init__(self, files: list, compress_mode: str, quality: int = None, resize_percent: int = 100): super().__init__() self.files = files self.compress_mode = compress_mode self.quality = quality self.resize_percent = resize_percent def run(self): results = [] total = len(self.files) for i, file_path in enumerate(self.files): try: result = self.compress_image(file_path) results.append(result) if result.get("success") and result.get("data"): self.file_processed.emit( file_path, result["data"], { "size": result["compressed_size"], "name": result.get("output_name", ""), "original_size": result["original_size"] } ) except Exception as e: logging.error(f"压缩失败 {file_path}: {e}") results.append({ "file": file_path, "success": False, "error": str(e) }) self.progress.emit(i + 1, total) self.finished.emit(results) def compress_image(self, file_path: str) -> dict: """压缩单个图片""" original_size = os.path.getsize(file_path) original_ext = Path(file_path).suffix.lower() # 获取原始格式 original_format = original_ext.lstrip('.') with Image.open(file_path) as img: original_width, original_height = img.size # 调整尺寸(如果需要) if self.resize_percent < 100: new_width = int(original_width * self.resize_percent / 100) new_height = int(original_height * self.resize_percent / 100) img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) # 压缩(保持原格式) compressed_data, ext = SmartCompressor.compress( img, original_format, self.compress_mode, self.quality ) compressed_size = len(compressed_data) # 如果压缩后反而变大,使用原文件 if compressed_size >= original_size and self.resize_percent == 100: with open(file_path, 'rb') as f: compressed_data = f.read() compressed_size = original_size ext = original_ext output_name = Path(file_path).stem + "_compressed" + ext return { "file": file_path, "output_name": output_name, "original_size": original_size, "compressed_size": compressed_size, "ratio": (1 - compressed_size / original_size) * 100 if original_size > 0 else 0, "success": True, "data": compressed_data } class ImageCompressPage(BaseWorkspace): """图片压缩页面""" def __init__(self, parent=None): super().__init__(parent) self.files = [] self.current_file_index = 0 self.processed_results = {} self.setup_compress_ui() def setup_compress_ui(self): """设置压缩UI""" self.history_btn.hide() self.export_btn.setText("💾 批量保存") self.export_btn.clicked.connect(self.batch_save) # 上传区域 self.upload_area = UploadArea("图片文件 (*.jpg *.jpeg *.png *.webp *.gif *.bmp)") self.upload_area.files_dropped.connect(self.on_files_added) self.content_layout.addWidget(self.upload_area) # 主内容区 content_widget = QWidget() content_layout = QHBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(24) # 左侧 - 预览区 self.preview_widget = DualPreviewWidget() self.preview_widget.save_requested.connect(self.on_file_saved) content_layout.addWidget(self.preview_widget, 2) # 右侧设置区 settings_frame = QFrame() settings_frame.setObjectName("card") settings_frame.setFixedWidth(300) settings_frame.setStyleSheet(""" #card { background: #1e293b; border: 1px solid #334155; border-radius: 16px; } """) settings_layout = QVBoxLayout(settings_frame) settings_layout.setContentsMargins(20, 20, 20, 20) settings_layout.setSpacing(16) # ====== 压缩模式 ====== mode_group = QGroupBox("🎯 压缩模式") mode_group.setStyleSheet(""" QGroupBox { font-weight: bold; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; margin-top: 8px; padding-top: 8px; } QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 8px; } """) mode_layout = QVBoxLayout(mode_group) mode_layout.setSpacing(6) self.mode_group = QButtonGroup(self) modes = [ ("visually", "🔒 视觉无损(推荐)", "肉眼几乎看不出差异", True), ("balanced", "⚖️ 均衡模式", "平衡质量与压缩率"), ("maximum", "🚀 极致压缩", "最大压缩,可能有轻微损失"), ("lossless", "💎 完全无损", "100%保留原质量"), ] for i, mode_data in enumerate(modes): mode_id, text, desc = mode_data[:3] is_default = len(mode_data) > 3 and mode_data[3] radio = QRadioButton(text) radio.setProperty("mode_id", mode_id) radio.setStyleSheet("color: #e2e8f0; font-size: 12px;") if is_default: radio.setChecked(True) self.mode_group.addButton(radio, i) mode_layout.addWidget(radio) desc_label = QLabel(f" {desc}") desc_label.setStyleSheet("color: #64748b; font-size: 10px;") mode_layout.addWidget(desc_label) settings_layout.addWidget(mode_group) # ====== 高级设置 ====== advanced_group = QGroupBox("⚙️ 高级选项") advanced_group.setStyleSheet(mode_group.styleSheet()) advanced_layout = QVBoxLayout(advanced_group) advanced_layout.setSpacing(10) # 手动质量 self.manual_quality_check = QCheckBox("手动指定质量") self.manual_quality_check.setStyleSheet("color: #cbd5e1; font-size: 12px;") self.manual_quality_check.stateChanged.connect(self.on_manual_quality_changed) advanced_layout.addWidget(self.manual_quality_check) quality_row = QHBoxLayout() self.quality_slider = QSlider(Qt.Orientation.Horizontal) self.quality_slider.setRange(50, 100) self.quality_slider.setValue(85) self.quality_slider.setEnabled(False) self.quality_slider.valueChanged.connect(self.on_quality_changed) quality_row.addWidget(self.quality_slider, 1) self.quality_label = QLabel("85%") self.quality_label.setStyleSheet("color: #fbbf24; font-weight: bold; min-width: 35px;") quality_row.addWidget(self.quality_label) advanced_layout.addLayout(quality_row) # 缩放 resize_row = QHBoxLayout() resize_row.addWidget(QLabel("尺寸:")) self.resize_combo = QComboBox() self.resize_combo.addItem("100% 原尺寸", 100) self.resize_combo.addItem("75%", 75) self.resize_combo.addItem("50%", 50) resize_row.addWidget(self.resize_combo, 1) advanced_layout.addLayout(resize_row) settings_layout.addWidget(advanced_group) # ====== 文件列表 ====== files_header = QHBoxLayout() files_label = QLabel("📁 待压缩文件") files_label.setStyleSheet("color: #e2e8f0; font-weight: bold; font-size: 12px;") files_header.addWidget(files_label) self.files_count = QLabel("0") self.files_count.setStyleSheet("color: #fbbf24;") files_header.addWidget(self.files_count) files_header.addStretch() clear_btn = QPushButton("清空") clear_btn.setObjectName("secondary_btn") clear_btn.setFixedWidth(50) clear_btn.clicked.connect(self.clear_files) files_header.addWidget(clear_btn) settings_layout.addLayout(files_header) self.files_list = QListWidget() self.files_list.setMaximumHeight(100) self.files_list.itemClicked.connect(self.on_file_clicked) settings_layout.addWidget(self.files_list) settings_layout.addStretch() # 进度条 self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) settings_layout.addWidget(self.progress_bar) # 预览按钮 self.preview_btn = QPushButton("👁️ 预览效果") self.preview_btn.setObjectName("secondary_btn") self.preview_btn.setMinimumHeight(38) self.preview_btn.clicked.connect(self.preview_current) settings_layout.addWidget(self.preview_btn) # 开始压缩按钮 self.compress_btn = QPushButton("⚡ 开始压缩") self.compress_btn.setObjectName("primary_btn") self.compress_btn.setMinimumHeight(45) self.compress_btn.setFont(QFont("Microsoft YaHei", 12, QFont.Weight.Bold)) self.compress_btn.clicked.connect(self.start_compress_all) settings_layout.addWidget(self.compress_btn) content_layout.addWidget(settings_frame) self.content_layout.addWidget(content_widget, 1) def get_compress_settings(self) -> dict: """获取压缩设置""" selected_btn = self.mode_group.checkedButton() mode = selected_btn.property("mode_id") if selected_btn else "visually" quality = None if self.manual_quality_check.isChecked(): quality = self.quality_slider.value() resize_percent = self.resize_combo.currentData() return {"mode": mode, "quality": quality, "resize": resize_percent} def on_manual_quality_changed(self, state): self.quality_slider.setEnabled(state == Qt.CheckState.Checked.value) def on_quality_changed(self, value: int): self.quality_label.setText(f"{value}%") def on_files_added(self, files: list): valid_exts = ('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp') for file_path in files: if file_path.lower().endswith(valid_exts): if file_path not in self.files: self.files.append(file_path) size = os.path.getsize(file_path) size_str = self.format_size(size) item = QListWidgetItem(f"📷 {Path(file_path).name} ({size_str})") item.setData(Qt.ItemDataRole.UserRole, file_path) self.files_list.addItem(item) self.files_count.setText(str(len(self.files))) if self.files: self.files_list.setCurrentRow(0) self.preview_widget.set_original(self.files[0]) self.current_file_index = 0 def on_file_clicked(self, item: QListWidgetItem): file_path = item.data(Qt.ItemDataRole.UserRole) self.current_file_index = self.files.index(file_path) self.preview_widget.set_original(file_path) if file_path in self.processed_results: result = self.processed_results[file_path] self.preview_widget.set_result( result["data"], {"size": result["compressed_size"], "name": result["output_name"]}, result["output_name"] ) def clear_files(self): self.files.clear() self.files_list.clear() self.files_count.setText("0") self.processed_results.clear() self.preview_widget.clear() def preview_current(self): if not self.files: QMessageBox.warning(self, "提示", "请先添加要压缩的图片") return settings = self.get_compress_settings() file_path = self.files[self.current_file_index] self.preview_btn.setEnabled(False) self.preview_btn.setText("处理中...") self.worker = CompressWorker( [file_path], settings["mode"], settings["quality"], settings["resize"] ) self.worker.file_processed.connect(self.on_preview_ready) self.worker.finished.connect(lambda: self.preview_btn.setEnabled(True)) self.worker.finished.connect(lambda: self.preview_btn.setText("👁️ 预览效果")) self.worker.start() def on_preview_ready(self, file_path: str, data: bytes, info: dict): output_name = info.get("name", Path(file_path).stem + "_compressed.jpg") self.preview_widget.set_result(data, info, output_name) self.processed_results[file_path] = { "data": data, "compressed_size": info.get("size", len(data)), "output_name": output_name } def start_compress_all(self): if not self.files: QMessageBox.warning(self, "提示", "请先添加要压缩的图片") return settings = self.get_compress_settings() self.compress_btn.setEnabled(False) self.progress_bar.setVisible(True) self.progress_bar.setValue(0) self.worker = CompressWorker( self.files, settings["mode"], settings["quality"], settings["resize"] ) self.worker.progress.connect(self.on_progress) self.worker.file_processed.connect(self.on_file_processed) self.worker.finished.connect(self.on_compress_finished) self.worker.start() def on_progress(self, current: int, total: int): self.progress_bar.setValue(int(current / total * 100)) def on_file_processed(self, file_path: str, data: bytes, info: dict): output_name = info.get("name", Path(file_path).stem + "_compressed.jpg") self.processed_results[file_path] = { "data": data, "compressed_size": info.get("size", len(data)), "output_name": output_name, "original_size": info.get("original_size", 0) } if self.files.index(file_path) == self.current_file_index: self.preview_widget.set_result(data, info, output_name) def on_compress_finished(self, results: list): self.compress_btn.setEnabled(True) self.progress_bar.setVisible(False) success = sum(1 for r in results if r.get("success")) total_orig = sum(r.get("original_size", 0) for r in results if r.get("success")) total_comp = sum(r.get("compressed_size", 0) for r in results if r.get("success")) saved = total_orig - total_comp if total_orig > 0: pct = (saved / total_orig) * 100 msg = (f"压缩完成!\n\n" f"✅ 成功: {success}/{len(results)}\n" f"📊 原始: {self.format_size(total_orig)}\n" f"📦 压缩后: {self.format_size(total_comp)}\n" f"💾 节省: {self.format_size(saved)} ({pct:.1f}%)") else: msg = f"压缩完成!\n✅ 成功: {success}/{len(results)}" QMessageBox.information(self, "完成", msg) def on_file_saved(self, path): logging.info(f"已保存: {path}") def batch_save(self): if not self.processed_results: QMessageBox.warning(self, "提示", "没有可保存的结果,请先压缩") return output_dir = QFileDialog.getExistingDirectory( self, "选择保存目录", config.get_output_directory() ) if not output_dir: return saved = 0 for fp, result in self.processed_results.items(): try: with open(os.path.join(output_dir, result["output_name"]), 'wb') as f: f.write(result["data"]) saved += 1 except Exception as e: logging.error(f"保存失败: {e}") QMessageBox.information(self, "完成", f"已保存 {saved} 个文件到:\n{output_dir}") @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"