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

'''

统信系统对扫描后的图片查看工具缺乏批量旋图功能,只能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()
相关推荐
小林ixn1 小时前
从 List 切片到 LLM 调用:一篇搞定 Python 基础与 AI 接口
python·ai编程
sugar__salt1 小时前
从Python列表切片到LLM接口实战:零基础AI编程落地教程
开发语言·python·ai·prompt·transformer·ai编程
乐于分享的阿乐1 小时前
Miniconda3 超详细安装配置教程(附安装包及学习资料)
python
2501_915106321 小时前
深入解析HTTPS抓包原理、中间人攻击及反抓包技术攻防
数据库·网络协议·ios·小程序·https·uni-app·iphone
gis分享者1 小时前
从原理到落地,Python 实现客户细分与销量预测
python·客户细分,销量预测,商业智能
小熊Coding2 小时前
Python二手图书市场行为分析系统
开发语言·爬虫·python·django·计算机毕业设计·数据可视化分析·二手图书分析系统
silvia_Anne2 小时前
微信小程序商品列表
微信小程序·小程序
AI算法沐枫2 小时前
机器学习经典小项目4:泰坦尼克号生存预测
人工智能·python·深度学习·线性代数·算法·机器学习·回归