'''
统信系统对扫描后的图片查看工具缺乏批量旋图功能,只能ctrl和r一个个的处理,中间的读图、保存的卡顿难以忍受。因此有必要请python重新制作一个支持拖拽的批量选图工具。windows 系统中字体均为宋体,否则为Fangsong Ti,所有字号均为12,窗口尺寸1000*500,底色淡绿色,有执行按键,停止按键。适应大量图片,至少110个。
windows系统默认打开位置为desktop,否则为/etc/huanghe/desktop/。记录上次打开的文件。鼠标选择多个图片后拖拽入窗口,作为被处理对象。所有设置和上次打开位置均为可记录。
设置区域:
旋转方向有顺时针、逆时针,90、180度。dpi选项有100、150、300、600、1200、2400,注意,如果dpi低于设定值则不得降低dpi,如果高于设定值则降低至设定值。并将此内容写在界面上。具象化图像质量。
功能:
点击打开按钮后打开上次打开的位置,鼠标框选多个图片并确定。
点击执行按钮则批量旋转并覆盖原图,点击执行并另存则自动在当前文件夹新建一个文件夹,将旋转后的图片批量存入该文件夹。
'''
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量旋转图片工具(体积优化版)
支持拖拽、批量旋转(90°/180°顺时针/逆时针)、DPI调整(100~2400)、覆盖/另存
修正:保存JPEG时使用自适应质量(默认85),避免体积翻倍
"""
import os
import sys
import threading
import time
import json
from pathlib import Path
from PIL import Image
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from typing import List, Tuple, Optional
try:
import tkinterdnd2 as tkdnd
DND_AVAILABLE = True
except ImportError:
DND_AVAILABLE = False
print("提示:安装 tkinterdnd2 可支持拖拽功能 (pip install tkinterdnd2)")
if sys.platform == 'win32':
DEFAULT_FONT = ('宋体', 12)
else:
DEFAULT_FONT = ('Fangsong Ti', 12)
if sys.platform == 'win32':
DEFAULT_PATH = os.path.expanduser("~/Desktop")
else:
DEFAULT_PATH = "/etc/huanghe/desktop/"
if not os.path.exists(DEFAULT_PATH):
DEFAULT_PATH = os.path.expanduser("~/Desktop")
CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".batch_rotate_config.json")
# ---------- 滤波器兼容性 ----------
def get_resample_filters():
if hasattr(Image, 'Resampling'):
if hasattr(Image.Resampling, 'LANCZOS'):
resize_filter = Image.Resampling.LANCZOS
else:
resize_filter = Image.Resampling.BICUBIC
else:
if hasattr(Image, 'LANCZOS'):
resize_filter = Image.LANCZOS
else:
resize_filter = Image.BICUBIC
if hasattr(Image, 'Resampling'):
rotate_filter = Image.Resampling.BICUBIC
else:
rotate_filter = Image.BICUBIC
return resize_filter, rotate_filter
RESIZE_FILTER, ROTATE_FILTER = get_resample_filters()
class BatchRotateApp:
def __init__(self, root):
self.root = root
self.root.title("批量旋转图片工具")
self.root.geometry("800x800")
self.root.configure(bg='#e0f0e8')
self.last_open_dir = self.load_last_dir()
self.image_paths: List[str] = []
self.running = False
self.stop_flag = False
self.rotation_dir = tk.StringVar(value="clockwise")
self.rotation_angle = tk.IntVar(value=90)
self.dpi_option = tk.IntVar(value=300)
self.quality_option = tk.IntVar(value=85)
self.load_settings()
self.create_widgets()
if DND_AVAILABLE:
self.setup_drag_drop()
self.rotation_dir.trace_add('write', lambda *a: self.save_settings())
self.rotation_angle.trace_add('write', lambda *a: self.save_settings())
self.dpi_option.trace_add('write', lambda *a: self.save_settings())
self.quality_option.trace_add('write', lambda *a: self.save_settings())
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
# ---------- 文件与配置 ----------
def load_last_dir(self) -> str:
config_file = os.path.join(os.path.expanduser("~"), ".batch_rotate_last_dir.txt")
if os.path.exists(config_file):
try:
with open(config_file, 'r') as f:
last_dir = f.read().strip()
if os.path.exists(last_dir):
return last_dir
except:
pass
return DEFAULT_PATH
def save_last_dir(self, path: str):
config_file = os.path.join(os.path.expanduser("~"), ".batch_rotate_last_dir.txt")
try:
with open(config_file, 'w') as f:
f.write(path)
except:
pass
def load_settings(self):
if not os.path.exists(CONFIG_FILE):
return
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
settings = json.load(f)
if 'rotation_dir' in settings:
self.rotation_dir.set(settings['rotation_dir'])
if 'rotation_angle' in settings:
self.rotation_angle.set(settings['rotation_angle'])
if 'dpi_option' in settings:
self.dpi_option.set(settings['dpi_option'])
if 'quality_option' in settings:
self.quality_option.set(settings['quality_option'])
except Exception as e:
print(f"加载设置失败: {e}")
def save_settings(self):
settings = {
'rotation_dir': self.rotation_dir.get(),
'rotation_angle': self.rotation_angle.get(),
'dpi_option': self.dpi_option.get(),
'quality_option': self.quality_option.get(),
}
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=2)
except Exception as e:
print(f"保存设置失败: {e}")
# ---------- 界面 ----------
def create_widgets(self):
left_frame = tk.Frame(self.root, bg='#e0f0e8')
left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=10, pady=10)
right_frame = tk.Frame(self.root, bg='#e0f0e8')
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=10, pady=10)
# 左侧面板
self.open_btn = tk.Button(left_frame, text="打开图片", font=DEFAULT_FONT,
command=self.open_images, bg="#c0e0d0")
self.open_btn.pack(pady=5, fill=tk.X)
dir_frame = tk.LabelFrame(left_frame, text="旋转方向", bg='#e0f0e8', font=DEFAULT_FONT)
dir_frame.pack(fill=tk.X, pady=5)
tk.Radiobutton(dir_frame, text="顺时针", variable=self.rotation_dir,
value="clockwise", bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
tk.Radiobutton(dir_frame, text="逆时针", variable=self.rotation_dir,
value="counterclockwise", bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
angle_frame = tk.LabelFrame(left_frame, text="旋转角度", bg='#e0f0e8', font=DEFAULT_FONT)
angle_frame.pack(fill=tk.X, pady=5)
tk.Radiobutton(angle_frame, text="90度", variable=self.rotation_angle,
value=90, bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
tk.Radiobutton(angle_frame, text="180度", variable=self.rotation_angle,
value=180, bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
dpi_frame = tk.LabelFrame(left_frame, text="DPI 设置", bg='#e0f0e8', font=DEFAULT_FONT)
dpi_frame.pack(fill=tk.X, pady=5)
for dpi_val in [100, 150, 300, 600, 1200, 2400]:
tk.Radiobutton(dpi_frame, text=f"{dpi_val} DPI", variable=self.dpi_option,
value=dpi_val, bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
dpi_desc = tk.Label(dpi_frame, text="规则:若原图DPI低于设定值则保持,\n高于设定值则降低到设定值",
bg='#e0f0e8', font=DEFAULT_FONT, justify=tk.LEFT)
dpi_desc.pack(anchor=tk.W, pady=(5,0))
quality_frame = tk.LabelFrame(left_frame, text="JPEG 图片质量", bg='#e0f0e8', font=DEFAULT_FONT)
quality_frame.pack(fill=tk.X, pady=5)
tk.Radiobutton(quality_frame, text="低 (70 - 体积小)", variable=self.quality_option,
value=70, bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
tk.Radiobutton(quality_frame, text="中 (85 - 平衡)", variable=self.quality_option,
value=85, bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
tk.Radiobutton(quality_frame, text="高 (90 - 清晰)", variable=self.quality_option,
value=90, bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
tk.Radiobutton(quality_frame, text="最高 (95 - 无损)", variable=self.quality_option,
value=95, bg='#e0f0e8', font=DEFAULT_FONT).pack(anchor=tk.W)
quality_desc = tk.Label(quality_frame, text="仅适用于JPEG格式,PNG使用无损压缩",
bg='#e0f0e8', font=DEFAULT_FONT, justify=tk.LEFT)
quality_desc.pack(anchor=tk.W, pady=(5,0))
self.execute_btn = tk.Button(left_frame, text="执行 (覆盖原图)", font=DEFAULT_FONT,
command=self.start_rotate_overwrite, bg="#90ee90")
self.execute_btn.pack(pady=5, fill=tk.X)
self.execute_saveas_btn = tk.Button(left_frame, text="执行并另存", font=DEFAULT_FONT,
command=self.start_rotate_saveas, bg="#90ee90")
self.execute_saveas_btn.pack(pady=5, fill=tk.X)
self.stop_btn = tk.Button(left_frame, text="停止", font=DEFAULT_FONT,
command=self.stop_processing, bg="#ffb6c1")
self.stop_btn.pack(pady=5, fill=tk.X)
self.progress_bar = ttk.Progressbar(left_frame, orient=tk.HORIZONTAL, length=200, mode='determinate')
self.progress_bar.pack(pady=10, fill=tk.X)
self.status_label = tk.Label(left_frame, text="就绪", bg='#e0f0e8', font=DEFAULT_FONT)
self.status_label.pack()
# 右侧文件列表
list_label = tk.Label(right_frame, text="已选图片列表 (支持拖拽添加)", bg='#e0f0e8', font=DEFAULT_FONT)
list_label.pack(anchor=tk.W)
list_frame = tk.Frame(right_frame, bg='#ffffff', bd=1, relief=tk.SUNKEN)
list_frame.pack(fill=tk.BOTH, expand=True)
self.listbox = tk.Listbox(list_frame, bg='#ffffff', font=DEFAULT_FONT,
selectmode=tk.EXTENDED, activestyle='none')
scrollbar = tk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.listbox.yview)
self.listbox.configure(yscrollcommand=scrollbar.set)
self.listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
if not DND_AVAILABLE:
drag_label = tk.Label(right_frame, text="拖拽功能未启用,请使用打开按钮", bg='#e0f0e8', fg='red', font=DEFAULT_FONT)
drag_label.pack(pady=5)
# else:
# drag_label = tk.Label(right_frame, text="可直接拖拽图片/文件夹到此区域", bg='#e0f0e8', font=DEFAULT_FONT)
# drag_label.pack(pady=5)
btn_frame = tk.Frame(right_frame, bg='#e0f0e8')
btn_frame.pack(fill=tk.X, pady=5)
tk.Button(btn_frame, text="删除选中", font=DEFAULT_FONT,
command=self.delete_selected, bg="#c0e0d0").pack(side=tk.LEFT, padx=27)
tk.Button(btn_frame, text="清空列表", font=DEFAULT_FONT,
command=self.clear_list, bg="#ffb6c1").pack(side=tk.LEFT, padx=17)
# ---------- 拖拽 ----------
def setup_drag_drop(self):
self.root.drop_target_register(tkdnd.DND_FILES)
self.root.dnd_bind('<<Drop>>', self.on_drop)
self.listbox.drop_target_register(tkdnd.DND_FILES)
self.listbox.dnd_bind('<<Drop>>', self.on_drop)
def on_drop(self, event):
raw_data = event.data
if sys.platform == 'win32':
raw_data = raw_data.strip('{}')
files = raw_data.split()
else:
files = self.root.tk.splitlist(raw_data)
added = 0
for f in files:
f = os.path.normpath(f)
if os.path.isdir(f):
for root_dir, _, filenames in os.walk(f):
for name in filenames:
if name.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.gif')):
full_path = os.path.join(root_dir, name)
if full_path not in self.image_paths:
self.image_paths.append(full_path)
added += 1
elif os.path.isfile(f) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.gif')):
if f not in self.image_paths:
self.image_paths.append(f)
added += 1
if added:
self.update_listbox()
self.status_label.config(text=f"已添加 {added} 个图片")
else:
self.status_label.config(text="未添加有效图片")
# ---------- 列表操作 ----------
def open_images(self):
files = filedialog.askopenfilenames(
initialdir=self.last_open_dir,
title="选择图片",
filetypes=[("图片文件", "*.png *.jpg *.jpeg *.bmp *.tiff *.gif"),
("所有文件", "*.*")]
)
if files:
dirpath = os.path.dirname(files[0])
self.last_open_dir = dirpath
self.save_last_dir(dirpath)
added = 0
for f in files:
if f not in self.image_paths:
self.image_paths.append(f)
added += 1
if added:
self.update_listbox()
self.status_label.config(text=f"添加了 {added} 个图片")
else:
self.status_label.config(text="所选图片已在列表中")
def update_listbox(self):
self.listbox.delete(0, tk.END)
for path in self.image_paths:
self.listbox.insert(tk.END, os.path.basename(path))
def delete_selected(self):
selected = self.listbox.curselection()
for idx in reversed(selected):
del self.image_paths[idx]
self.update_listbox()
self.status_label.config(text=f"已删除 {len(selected)} 个图片")
def clear_list(self):
self.image_paths.clear()
self.update_listbox()
self.status_label.config(text="已清空列表")
# ---------- 图像处理核心(体积优化)----------
def get_image_dpi(self, img_path: str) -> Tuple[int, int]:
try:
with Image.open(img_path) as im:
dpi = im.info.get('dpi', (72, 72))
if dpi is None:
return (72, 72)
return dpi
except:
return (72, 72)
def adjust_image_dpi(self, img: Image.Image, target_dpi: int) -> Image.Image:
src_dpi = img.info.get('dpi', (72, 72))
if isinstance(src_dpi, tuple):
current_dpi = src_dpi[0]
else:
current_dpi = src_dpi
if current_dpi is None:
current_dpi = 72
if current_dpi > target_dpi:
scale = target_dpi / current_dpi
new_size = (int(img.width * scale), int(img.height * scale))
img_resized = img.resize(new_size, RESIZE_FILTER)
img_resized.info['dpi'] = (target_dpi, target_dpi)
return img_resized
else:
if 'dpi' not in img.info or img.info['dpi'] is None:
img.info['dpi'] = (current_dpi, current_dpi)
return img
def _get_save_quality(self, img_path: str, is_overwrite: bool) -> int:
"""
返回用户选择的 JPEG 质量
"""
return self.quality_option.get()
def rotate_single_image(self, img_path: str, output_path: str, dpi_target: int,
angle: int, direction: str, is_overwrite: bool) -> bool:
try:
with Image.open(img_path) as img:
# 格式判断
fmt = img.format
img = self.adjust_image_dpi(img, dpi_target)
if direction == "clockwise":
rot_angle = -angle
else:
rot_angle = angle
rotated = img.rotate(rot_angle, expand=True, resample=ROTATE_FILTER)
# 保存参数
save_kwargs = {}
if fmt == 'JPEG':
quality = self._get_save_quality(img_path, is_overwrite)
save_kwargs = {
'quality': quality,
'optimize': True,
'progressive': True,
'subsampling': 1 # 保持最佳色度采样
}
elif fmt == 'PNG':
save_kwargs = {'compress_level': 6, 'optimize': True}
else:
# 其他格式默认用高质量保存
save_kwargs = {'quality': 95, 'optimize': True}
rotated.save(output_path, **save_kwargs)
return True
except Exception as e:
print(f"处理 {img_path} 失败: {e}")
return False
# ---------- 批量处理 ----------
def start_rotate_overwrite(self):
self.start_processing(overwrite=True)
def start_rotate_saveas(self):
self.start_processing(overwrite=False)
def start_processing(self, overwrite: bool):
if not self.image_paths:
messagebox.showwarning("警告", "没有图片可处理")
return
if self.running:
messagebox.showinfo("提示", "已有任务正在执行")
return
self.running = True
self.stop_flag = False
self.status_label.config(text="处理中...")
self.progress_bar['value'] = 0
self.progress_bar['maximum'] = len(self.image_paths)
direction = self.rotation_dir.get()
angle = self.rotation_angle.get()
target_dpi = self.dpi_option.get()
if overwrite:
output_paths = self.image_paths[:]
else:
current_dir = os.path.dirname(self.image_paths[0]) if self.image_paths else ""
new_folder = os.path.join(current_dir, "旋转后")
os.makedirs(new_folder, exist_ok=True)
output_paths = []
for p in self.image_paths:
name, ext = os.path.splitext(os.path.basename(p))
new_name = f"{name}_rotated{ext}"
output_paths.append(os.path.join(new_folder, new_name))
threading.Thread(target=self.process_images,
args=(self.image_paths, output_paths, target_dpi, angle, direction, overwrite),
daemon=True).start()
def process_images(self, inputs, outputs, target_dpi, angle, direction, overwrite):
total = len(inputs)
success_count = 0
for idx, (src, dst) in enumerate(zip(inputs, outputs)):
if self.stop_flag:
break
ok = self.rotate_single_image(src, dst, target_dpi, angle, direction, overwrite)
if ok:
success_count += 1
self.root.after(0, self.update_progress, idx+1, total, success_count)
time.sleep(0.01)
self.root.after(0, self.processing_finished, success_count, total, overwrite, (outputs[0] if outputs else ""))
def update_progress(self, current, total, success):
self.progress_bar['value'] = current
self.status_label.config(text=f"已处理 {current}/{total},成功 {success}")
def processing_finished(self, success_count, total, overwrite, sample_output):
self.running = False
if self.stop_flag:
self.status_label.config(text=f"已停止,成功处理 {success_count}/{total}")
messagebox.showinfo("提示", f"已停止,成功处理 {success_count}/{total} 个图片")
else:
self.status_label.config(text=f"处理完成,成功 {success_count}/{total}")
if overwrite:
messagebox.showinfo("完成", f"成功覆盖 {success_count}/{total} 个图片")
else:
folder = os.path.dirname(sample_output)
messagebox.showinfo("完成", f"已另存 {success_count}/{total} 个图片到文件夹:\n{folder}")
self.progress_bar['value'] = 0
def stop_processing(self):
if self.running:
self.stop_flag = True
self.status_label.config(text="正在停止...")
else:
messagebox.showinfo("提示", "没有运行中的任务")
def on_closing(self):
if self.running:
if messagebox.askyesno("确认", "有任务正在运行,确定要退出吗?"):
self.stop_flag = True
self.root.destroy()
else:
self.root.destroy()
def main():
if DND_AVAILABLE:
root = tkdnd.Tk()
else:
root = tk.Tk()
app = BatchRotateApp(root)
root.mainloop()
if __name__ == "__main__":
main()