统信小程序(十四)支持拖拽的旋图程序

'''

统信系统对扫描后的图片查看工具缺乏批量旋图功能,只能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()
相关推荐
CTA量化套保6 小时前
近期零基础量化学习:先分阶段,再用 AI 检查缺口
人工智能·python
清水白石0087 小时前
Python 死锁排查全攻略:从线程卡死到锁依赖定位与工程化修复
linux·网络·python
Elias不吃糖7 小时前
Langfuse 入门:Trace、Prompt、Dataset、Experiment、Evaluator
前端·python·prompt·langfuse
TechWayfarer7 小时前
批量查IP归属地,在线API、脚本、离线库怎么选?三种方案实测对比
网络·python·网络协议·tcp/ip
七牛开发者8 小时前
为什么 Go 很适合 AI 辅助开发?
数据库·人工智能·python·elasticsearch·log4j
河南凹凸环境艺术设计9 小时前
性价比高的民宿酒店设计企业
大数据·人工智能·python
wuyk55512 小时前
Python 第三章:列表 List
服务器·开发语言·python
笨鸟先飞,勤能补拙12 小时前
从预测到决策:人工智能与机器学习的系统方法、工程闭环与现实边界
大数据·人工智能·windows·python·机器学习·密码学
MgArcher12 小时前
抖音a_bogus参数逆向实战:JSVMP环境模拟与Hook技术(2025最新)
javascript·爬虫·python
circuitsosk13 小时前
AI输出的“质检员”:构建智能体质量评估、异常检测与人工兜底的三层防线
人工智能·python·microsoft·正则表达式·langchain