# -*- coding: utf-8 -*-"""图片格式转换工具功能:1. AVIF -> JPG/JPEG 专用快速转换(支持批量、文件夹递归、质量调整)2. 通用图片格式互转(所有 Pillow 支持的读取/写入格式之间互转)依赖:Pillow >= 9.1(需带 AVIF 支持,Pillow 11+ 默认内置 libavif)"""from __future__ import annotationsimport osimport sysimport threadingimport tracebackfrom pathlib import Pathfrom typing import Iterablefrom PIL import Image, ImageFile# 允许 Pillow 处理截断的图片ImageFile.LOAD_TRUNCATED_IMAGES = Truetry: import tkinter as tk from tkinter import filedialog, messagebox, ttkexcept ImportError as e: # pragma: no cover raise SystemExit("未找到 tkinter,请安装 Python 的 Tk 包。") from e# ---------------------------------------------------------------------------# 格式探测# ---------------------------------------------------------------------------def _norm_ext(ext: str) -> str: """统一扩展名表示,去掉前导点并小写。""" return ext.lstrip(".").lower()def discover_formats() -> dict: """ 探测当前 Pillow 支持的扩展名。 返回 dict: { "read": {ext_lower: format_name, ...}, # 可读取的扩展名 -> 格式名 "write": {ext_lower: format_name, ...}, # 可写入的扩展名 -> 格式名 } 说明: Pillow 内部用格式名(如 "JPEG")注册 OPEN/SAVE 函数, 而 registered_extensions() 返回 {ext: ImageFormat}, ImageFormat 的 .name 就是格式名。 """ read_map: dict[str, str] = {} write_map: dict[str, str] = {} for ext, fmt in Image.registered_extensions().items(): key = _norm_ext(ext) # registered_extensions 的 fmt 可能是 ImageFormat 对象或字符串 if isinstance(fmt, str): fmt_name = fmt else: fmt_name = getattr(fmt, "name", str(fmt)) # 用格式名查 OPEN/SAVE(Pillow 内部字典以格式名为键) if fmt_name in Image.OPEN: read_map[key] = fmt_name if fmt_name in Image.SAVE: write_map[key] = fmt_name # 同义扩展名补全(registered_extensions 通常已包含,这里兜底) synonyms = { "jpg": "jpeg", "avifs": "avif", "tif": "tiff", "tff": "tiff", "jfif": "jpeg", } for short, long_ in synonyms.items(): if long_ in read_map and short not in read_map: read_map[short] = read_map[long_] if long_ in write_map and short not in write_map: write_map[short] = write_map[long_] return {"read": read_map, "write": write_map}# 一些格式在保存时需要特殊处理(模式转换、扩展名匹配等)_FORMAT_SAVE_HINTS: dict[str, dict] = { # JPEG/JPG 不支持 alpha 通道 "jpeg": {"modes": ("RGB",)}, "jpg": {"modes": ("RGB",)}, # BMP 仅支持有限模式 "bmp": {"modes": ("RGB", "RGBA", "P", "L")}, # GIF 限制调色板 "gif": {"modes": ("P", "L")}, # EPS / PDF 等向量/复合格式,转过去需要 RGBA->RGB 等处理 "pdf": {"modes": ("RGB",)}, "eps": {"save_kwargs": {"scale": 1.0}}, # ICO / ICNS 尺寸集合有限制 "ico": {"sizes": ((16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256))}, "icns": {"sizes": ((16, 16), (32, 32), (64, 64), (128, 128), (256, 256), (512, 512))}, # WebP 保留透明 "webp": {}, "avif": {}, "png": {}, "tiff": {},}def _save_image(img: Image.Image, target: Path, fmt_ext: str, quality: int | None = None) -> None: """根据目标扩展名调用 Image.save,处理模式转换与默认参数。""" fmt_ext = _norm_ext(fmt_ext) target.parent.mkdir(parents=True, exist_ok=True) hint = _FORMAT_SAVE_HINTS.get(fmt_ext, {}) save_kwargs: dict = {} # 目标格式名(PIL 接受 jpeg/png/webp 等格式名) fmt_name_map = discover_formats()["write"] fmt_id = fmt_name_map.get(fmt_ext) or fmt_ext.upper() # 模式转换 allowed_modes = hint.get("modes") if allowed_modes: if img.mode not in allowed_modes: if "RGB" in allowed_modes and img.mode in ("RGBA", "LA", "P"): bg = Image.new("RGB", img.size, (255, 255, 255)) if img.mode == "P": img = img.convert("RGBA") if img.mode in ("RGBA", "LA"): bg.paste(img, mask=img.split()[-1]) else: bg.paste(img) img = bg else: img = img.convert(allowed_modes[0]) # JPEG 系列质量 if fmt_ext in ("jpeg", "jpg"): save_kwargs.update({"quality": quality if quality is not None else 95, "subsampling": 0, "optimize": True}) if img.mode in ("RGBA", "LA", "P"): bg = Image.new("RGB", img.size, (255, 255, 255)) if img.mode == "P": img = img.convert("RGBA") if img.mode == "RGBA": bg.paste(img, mask=img.split()[-1]) else: bg.paste(img) img = bg elif fmt_ext == "webp": save_kwargs.update({"quality": quality if quality is not None else 90, "method": 6}) elif fmt_ext == "avif": # quality 0-100,Pillow 把它转给 libavif save_kwargs.update({"quality": quality if quality is not None else 80}) elif fmt_ext == "png": save_kwargs.update({"optimize": True}) elif fmt_ext == "gif": save_kwargs.update({"optimize": True, "disposal": 2}) # ICO/ICNS 尺寸约束 sizes = hint.get("sizes") if sizes: save_kwargs["sizes"] = sizes save_kwargs.update(hint.get("save_kwargs", {})) # 用 format=fmt_id 显式指定,避免仅靠扩展名歧义 img.save(target, format=fmt_id, **save_kwargs)# ---------------------------------------------------------------------------# 转换核心# ---------------------------------------------------------------------------def _iter_files(paths: Iterable[str], recursive: bool, exts: set[str]) -> list[Path]: """从路径列表展开为文件列表,按扩展名过滤。""" out: list[Path] = [] for p in paths: path = Path(p) if path.is_file(): if _norm_ext(path.suffix) in exts: out.append(path) elif path.is_dir(): glob = "**/*" if recursive else "*" for f in path.glob(glob): if f.is_file() and _norm_ext(f.suffix) in exts: out.append(f) # 去重保序 seen = set() deduped = [] for f in out: rp = f.resolve() if rp not in seen: seen.add(rp) deduped.append(f) return dedupeddef convert_one(src: Path, dst_ext: str, out_dir: Path | None, quality: int | None, keep_dir: bool, src_root: Path | None, overwrite: bool) -> tuple[bool, str]: """转换单个文件。返回 (success, message)。""" try: with Image.open(src) as img: # 处理多帧(GIF/APNG/动画 WebP):取首帧 try: img.seek(0) except (EOFError, AttributeError): pass # 计算输出路径 if out_dir is None: target_dir = src.parent else: if keep_dir and src_root is not None: rel = src.relative_to(src_root).parent target_dir = out_dir / rel else: target_dir = out_dir target = target_dir / f"{src.stem}.{_norm_ext(dst_ext)}" if target.exists() and not overwrite: return False, f"已存在(跳过): {target}" _save_image(img, target, dst_ext, quality) return True, str(target) except Exception as e: return False, f"失败 {src.name}: {e}\n{traceback.format_exc()}"# ---------------------------------------------------------------------------# GUI# ---------------------------------------------------------------------------class ConverterApp(tk.Tk): def __init__(self): super().__init__() self.title("图片格式转换工具 (AVIF→JPG/JPEG · 通用互转)") self.geometry("900x680") self.minsize(780, 600) self.fmts = discover_formats() self._build_ui() # ------------------------------------------------------------------ UI def _build_ui(self): nb = ttk.Notebook(self) nb.pack(fill="both", expand=True, padx=8, pady=8) self.avif_tab = ttk.Frame(nb) self.generic_tab = ttk.Frame(nb) nb.add(self.avif_tab, text="AVIF → JPG/JPEG") nb.add(self.generic_tab, text="通用格式互转") self._build_avif_tab() self._build_generic_tab() self.status_var = tk.StringVar(value="就绪。") bar = ttk.Frame(self) bar.pack(fill="x", side="bottom") ttk.Label(bar, textvariable=self.status_var, anchor="w").pack( fill="x", padx=8, pady=4) # -------- AVIF 专用 -------- def _build_avif_tab(self): t = self.avif_tab t.columnconfigure(1, weight=1) # 输入 ttk.Label(t, text="输入 AVIF 文件/文件夹:").grid( row=0, column=0, sticky="w", padx=8, pady=8) self.avif_input_var = tk.StringVar() ttk.Entry(t, textvariable=self.avif_input_var).grid( row=0, column=1, sticky="we", padx=8, pady=8) ttk.Button(t, text="添加文件…", command=lambda: self._pick_files(self.avif_input_var)).grid( row=0, column=2, padx=4, pady=8) ttk.Button(t, text="选文件夹…", command=lambda: self._pick_dir(self.avif_input_var)).grid( row=0, column=3, padx=4, pady=8) # 输出目录 ttk.Label(t, text="输出目录(留空=源目录):").grid( row=1, column=0, sticky="w", padx=8, pady=8) self.avif_out_var = tk.StringVar() ttk.Entry(t, textvariable=self.avif_out_var).grid( row=1, column=1, sticky="we", padx=8, pady=8) ttk.Button(t, text="选目录…", command=lambda: self._pick_dir(self.avif_out_var, is_dir=True)).grid( row=1, column=2, columnspan=2, sticky="we", padx=4, pady=8) # 目标格式 ttk.Label(t, text="目标格式:").grid( row=2, column=0, sticky="w", padx=8, pady=8) self.avif_tgt_var = tk.StringVar(value="jpg") ft = ttk.Frame(t) ft.grid(row=2, column=1, sticky="w", padx=8, pady=8) ttk.Radiobutton(ft, text="JPG (.jpg)", value="jpg", variable=self.avif_tgt_var).pack(side="left") ttk.Radiobutton(ft, text="JPEG (.jpeg)", value="jpeg", variable=self.avif_tgt_var).pack(side="left", padx=12) # 选项 opts = ttk.Frame(t) opts.grid(row=3, column=0, columnspan=4, sticky="w", padx=8, pady=4) self.avif_recursive = tk.BooleanVar(value=True) self.avif_keepdir = tk.BooleanVar(value=True) self.avif_overwrite = tk.BooleanVar(value=False) ttk.Checkbutton(opts, text="文件夹递归子目录", variable=self.avif_recursive).pack(side="left") ttk.Checkbutton(opts, text="保留相对目录结构", variable=self.avif_keepdir).pack(side="left", padx=12) ttk.Checkbutton(opts, text="覆盖已存在文件", variable=self.avif_overwrite).pack(side="left") # 质量 qf = ttk.Frame(t) qf.grid(row=4, column=0, columnspan=4, sticky="we", padx=8, pady=4) ttk.Label(qf, text="JPEG 质量:").pack(side="left") self.avif_quality = tk.IntVar(value=95) self.avif_qscale = ttk.Scale(qf, from_=10, to=100, variable=self.avif_quality, orient="horizontal", command=lambda v: self.avif_quality.set(int(float(v)))) self.avif_qscale.pack(side="left", fill="x", expand=True, padx=8) self.avif_qlbl = ttk.Label(qf, textvariable=self.avif_quality, width=4) self.avif_qlbl.pack(side="left") # 按钮 bf = ttk.Frame(t) bf.grid(row=5, column=0, columnspan=4, sticky="we", padx=8, pady=8) bf.columnconfigure(0, weight=1) self.avif_btn = ttk.Button(bf, text="开始转换 AVIF → JPG/JPEG", command=self._run_avif) self.avif_btn.pack(fill="x") # 日志 ttk.Label(t, text="日志:").grid(row=6, column=0, sticky="nw", padx=8, pady=4) log_frame = ttk.Frame(t) log_frame.grid(row=6, column=1, columnspan=3, sticky="nsew", padx=8, pady=4) t.rowconfigure(6, weight=1) log_frame.columnconfigure(0, weight=1) log_frame.rowconfigure(0, weight=1) self.avif_log = tk.Text(log_frame, height=12, wrap="none", font=("Consolas", 9)) self.avif_log.grid(row=0, column=0, sticky="nsew") sb = ttk.Scrollbar(log_frame, command=self.avif_log.yview) sb.grid(row=0, column=1, sticky="ns") self.avif_log.configure(yscrollcommand=sb.set) # -------- 通用互转 -------- def _build_generic_tab(self): t = self.generic_tab t.columnconfigure(1, weight=1) # 输入 ttk.Label(t, text="输入文件/文件夹:").grid( row=0, column=0, sticky="w", padx=8, pady=8) self.gen_input_var = tk.StringVar() ttk.Entry(t, textvariable=self.gen_input_var).grid( row=0, column=1, sticky="we", padx=8, pady=8) ttk.Button(t, text="添加文件…", command=lambda: self._pick_files(self.gen_input_var)).grid( row=0, column=2, padx=4, pady=8) ttk.Button(t, text="选文件夹…", command=lambda: self._pick_dir(self.gen_input_var)).grid( row=0, column=3, padx=4, pady=8) # 输出目录 ttk.Label(t, text="输出目录(留空=源目录):").grid( row=1, column=0, sticky="w", padx=8, pady=8) self.gen_out_var = tk.StringVar() ttk.Entry(t, textvariable=self.gen_out_var).grid( row=1, column=1, sticky="we", padx=8, pady=8) ttk.Button(t, text="选目录…", command=lambda: self._pick_dir(self.gen_out_var, is_dir=True)).grid( row=1, column=2, columnspan=2, sticky="we", padx=4, pady=8) # 目标格式下拉(仅显示可写入的扩展名) ttk.Label(t, text="目标格式:").grid( row=2, column=0, sticky="w", padx=8, pady=8) write_exts = sorted(self.fmts["write"].keys()) self.gen_tgt_var = tk.StringVar(value="png" if "png" in write_exts else (write_exts[0] if write_exts else "png")) self.gen_tgt_combo = ttk.Combobox( t, textvariable=self.gen_tgt_var, values=write_exts, state="readonly", width=12) self.gen_tgt_combo.grid(row=2, column=1, sticky="w", padx=8, pady=8) ttk.Label(t, text=f"(共 {len(write_exts)} 种可写格式)").grid( row=2, column=2, columnspan=2, sticky="w", padx=8) # 源格式说明 ttk.Label( t, text=f"支持的读取格式: {', '.join(sorted(self.fmts['read'].keys()))}", wraplength=760, justify="left", foreground="#666").grid( row=3, column=0, columnspan=4, sticky="we", padx=8, pady=4) # 选项 opts = ttk.Frame(t) opts.grid(row=4, column=0, columnspan=4, sticky="w", padx=8, pady=4) self.gen_recursive = tk.BooleanVar(value=True) self.gen_keepdir = tk.BooleanVar(value=True) self.gen_overwrite = tk.BooleanVar(value=False) self.gen_skip_same = tk.BooleanVar(value=True) ttk.Checkbutton(opts, text="文件夹递归子目录", variable=self.gen_recursive).pack(side="left") ttk.Checkbutton(opts, text="保留相对目录结构", variable=self.gen_keepdir).pack(side="left", padx=12) ttk.Checkbutton(opts, text="覆盖已存在文件", variable=self.gen_overwrite).pack(side="left") ttk.Checkbutton(opts, text="源=目标扩展名则跳过", variable=self.gen_skip_same).pack(side="left", padx=12) # 质量 qf = ttk.Frame(t) qf.grid(row=5, column=0, columnspan=4, sticky="we", padx=8, pady=4) ttk.Label(qf, text="质量(对 JPG/WEBP/AVIF 有效):").pack(side="left") self.gen_quality = tk.IntVar(value=95) self.gen_qscale = ttk.Scale(qf, from_=10, to=100, variable=self.gen_quality, orient="horizontal", command=lambda v: self.gen_quality.set(int(float(v)))) self.gen_qscale.pack(side="left", fill="x", expand=True, padx=8) self.gen_qlbl = ttk.Label(qf, textvariable=self.gen_quality, width=4) self.gen_qlbl.pack(side="left") # 按钮 bf = ttk.Frame(t) bf.grid(row=6, column=0, columnspan=4, sticky="we", padx=8, pady=8) bf.columnconfigure(0, weight=1) self.gen_btn = ttk.Button(bf, text="开始通用转换", command=self._run_generic) self.gen_btn.pack(fill="x") # 日志 ttk.Label(t, text="日志:").grid(row=7, column=0, sticky="nw", padx=8, pady=4) log_frame = ttk.Frame(t) log_frame.grid(row=7, column=1, columnspan=3, sticky="nsew", padx=8, pady=4) t.rowconfigure(7, weight=1) log_frame.columnconfigure(0, weight=1) log_frame.rowconfigure(0, weight=1) self.gen_log = tk.Text(log_frame, height=12, wrap="none", font=("Consolas", 9)) self.gen_log.grid(row=0, column=0, sticky="nsew") sb = ttk.Scrollbar(log_frame, command=self.gen_log.yview) sb.grid(row=0, column=1, sticky="ns") self.gen_log.configure(yscrollcommand=sb.set) # -------------------------------------------------------------- 工具 def _pick_files(self, var: tk.StringVar): files = filedialog.askopenfilenames( title="选择图片文件", filetypes=[("图片", "." + " *.".join(sorted(self.fmts["read"].keys()))), ("所有文件", "*.*")]) if files: # 多文件用换行分隔显示 existing = var.get().strip() new = "\n".join(files) var.set(new if not existing else existing + "\n" + new) def _pick_dir(self, var: tk.StringVar, is_dir: bool = True): if is_dir: d = filedialog.askdirectory(title="选择文件夹") if d: var.set(d) else: f = filedialog.askopenfilename(title="选择文件") if f: var.set(f) def _log(self, widget: tk.Text, msg: str): # 线程安全:通过 after 调度到主线程 self.after(0, self._do_log, widget, msg) def _do_log(self, widget: tk.Text, msg: str): widget.insert("end", msg + "\n") widget.see("end") def _set_busy(self, busy: bool): self.after(0, self._do_set_busy, busy) def _do_set_busy(self, busy: bool): for btn in (self.avif_btn, self.gen_btn): btn.config(state="disabled" if busy else "normal") self.status_var.set("处理中…" if busy else "就绪。") def _set_status(self, text: str): self.after(0, self.status_var.set, text) # -------------------------------------------------------------- 运行 def _parse_paths(self, text: str) -> list[str]: # 支持换行或分号分隔 parts = [p.strip() for p in text.replace(";", "\n").split("\n") if p.strip()] return parts def _run_avif(self): inputs = self._parse_paths(self.avif_input_var.get()) if not inputs: messagebox.showwarning("提示", "请先选择 AVIF 文件或文件夹。") return tgt = self.avif_tgt_var.get() out_dir = self.avif_out_var.get().strip() out_dir_p = Path(out_dir) if out_dir else None recursive = self.avif_recursive.get() keep_dir = self.avif_keepdir.get() overwrite = self.avif_overwrite.get() quality = int(self.avif_quality.get()) # 源扩展名集合:avif 及其变体 src_exts = {"avif", "avifs", "heif", "heic"} & self.fmts["read"].keys() if not src_exts: messagebox.showerror("错误", "当前 Pillow 不支持读取 AVIF。") return files = _iter_files(inputs, recursive, src_exts) self.avif_log.delete("1.0", "end") if not files: self._log(self.avif_log, "未找到任何 AVIF 文件。") return src_root = self._common_root([Path(p) for p in inputs]) self._set_busy(True) threading.Thread( target=self._worker, args=(files, tgt, out_dir_p, quality, keep_dir, overwrite, self.avif_log, src_root), daemon=True).start() def _run_generic(self): inputs = self._parse_paths(self.gen_input_var.get()) if not inputs: messagebox.showwarning("提示", "请先选择输入文件或文件夹。") return tgt = _norm_ext(self.gen_tgt_var.get()) out_dir = self.gen_out_var.get().strip() out_dir_p = Path(out_dir) if out_dir else None recursive = self.gen_recursive.get() keep_dir = self.gen_keepdir.get() overwrite = self.gen_overwrite.get() skip_same = self.gen_skip_same.get() quality = int(self.gen_quality.get()) src_exts = set(self.fmts["read"].keys()) files = _iter_files(inputs, recursive, src_exts) self.gen_log.delete("1.0", "end") if not files: self._log(self.gen_log, "未找到任何可识别的图片文件。") return src_root = self._common_root([Path(p) for p in inputs]) self._set_busy(True) threading.Thread( target=self._worker, args=(files, tgt, out_dir_p, quality, keep_dir, overwrite, self.gen_log, src_root, skip_same), daemon=True).start() def _worker(self, files, tgt, out_dir, quality, keep_dir, overwrite, log_widget, src_root=None, skip_same=False): try: total = len(files) ok = 0 fail = 0 for i, f in enumerate(files, 1): self._set_status(f"[{i}/{total}] {f.name}") # 跳过同扩展名 if skip_same and _norm_ext(f.suffix) == _norm_ext(tgt): self._log(log_widget, f"[跳过-同格式] {f}") continue success, msg = convert_one( f, tgt, out_dir, quality, keep_dir, src_root, overwrite) if success: ok += 1 self._log(log_widget, f"[OK] {f.name} -> {msg}") else: fail += 1 self._log(log_widget, f"[FAIL] {msg}") self._log(log_widget, f"\n=== 完成:成功 {ok},失败 {fail},共 {total} ===") except Exception as e: self._log(log_widget, f"严重错误: {e}\n{traceback.format_exc()}") finally: self._set_busy(False) @staticmethod def _common_root(paths: list[Path]) -> Path | None: if not paths: return None if len(paths) == 1: p = paths[0] return p if p.is_dir() else p.parent # 计算公共父目录 common = list(paths[0].resolve().parts) for p in paths[1:]: parts = list(p.resolve().parts) new_common = [] for a, b in zip(common, parts): if a == b: new_common.append(a) else: break common = new_common if not common: break if common: return Path(*common) return Nonedef main(): app = ConverterApp() app.mainloop()if __name__ == "__main__": main()