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

'''

统信系统对扫描后的图片查看工具缺乏批量旋图功能,只能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()
相关推荐
羲云网络3 小时前
GPT Image 2.6 接入指南:Flare、Sunburst 怎么选,编辑失败如何排查
人工智能·python·aigc·图像生成·acedatacloud
橙橙笔记6 小时前
具身智能的学习 - 完成一个机械臂 第1节
python·学习·机械臂·具身智能
小小张说故事7 小时前
Python 的 class 语句到底执行了什么?拆开 __prepare__、metaclass.__new__ 和那个被讲反的 __set_name__ 顺序
python
天天被压力9 小时前
【A股分钟级高频数据完全手册 #07】北交所历史分钟K线接口:京市Pro版分时批量
java·人工智能·python
天天被压力9 小时前
【A股分钟级高频数据完全手册 #08】股票日、分时数据接口对比:普通分时与Pro版差异实测
java·人工智能·python
特立独行的猫A9 小时前
AI 时代,我为什么推荐用 cj-tauri 开发桌面应用
java·后端·python
小lo想吃棒棒糖9 小时前
把机器人训练从 GPU 搬到昇腾 NPU:启元 Q1 起身动作 PPO 训练全记录
python·机器人·昇腾·启元机器人
Thanks_ks10 小时前
【第 006 讲】搭建 PyCharm 开发环境:PyCharm 的下载与安装 | 初始设置与常用配置
ide·python·pycharm·开发工具·jetbrains·开发环境·集成开发环境
wafrdggh10 小时前
2026 商城小程序平台哪个价格合理?把跨境附加成本这个坑算透
大数据·小程序
青少儿编程课堂11 小时前
蓝桥杯青少组图形化省考报名倒计时·全国青少年信息素养大赛·电子学会图形化考级:校园黑白棋人机对战解析
c++·python·算法·bfs·信息学竞赛