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

'''

统信系统对扫描后的图片查看工具缺乏批量旋图功能,只能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()
相关推荐
SunnyDays101129 分钟前
Python PDF 转 Markdown 详解(转换整个文档,特定页面,表格,扫描 PDF)
python·pdf 转 markdown·pdf 转 md·扫描 pdf 转 md·pdf 表格转 md
2601_954706491 小时前
云手机 API 自动化实战:Python 批量控机、挂机脚本完整实现
python·智能手机·自动化
黄华SJ520it1 小时前
新零售排队免单系统开发全流程介绍
小程序·零售·系统开发
Alan_6914 小时前
聊聊 Swagger/Postman/Apifox 的真实选型与 Python 项目实战
python·测试工具·postman
智慧物业老杨5 小时前
物业绿化数智化权责管控方案:基于工单平台的双层权限追溯模块落地实践
python
Metaphor6926 小时前
使用 Python 在 Word 文档中添加批注
python·word
FreakStudio6 小时前
WIZnet 开源方案分享:W55MH32 开发板 + 小智 AI实现以太网版本的小智机器人
python·单片机·嵌入式·大学生·面向对象·并行计算·电子diy·电子计算机
会周易的程序员6 小时前
使用 pybind11 封装 C++ 日志库 microLog 为 Python 模块
java·c++·python·物联网·架构·日志·aiot
一只专注api接口开发的技术猿7 小时前
电商评论自动化监控与情感数据分析完整落地教程(附可直接运行 Python 代码)
大数据·数据库·python·数据分析·自动化
HAPPY酷7 小时前
【ROS2】 Conda 环境搭建(命令行版)
linux·运维·python·ubuntu·机器人·conda