背景:
由于近些天需要辅助照片打印,而摄影师和打印彼此独立进行,不能使用像“拍马传”这种联网传照片,摄影打印一体化的方式。因此拿到摄影师照片的方式只能是拿他的摄像机的存储卡。随后进行存储卡的轮换。打印时再借助拍马,将需要打印的照片复制到打印文件夹中,拍马便会自动打印对应的图片,并将文件夹中的图片删除。
但是上述做法存在几个问题:
①轮换存储卡时,你可能无法记得之前拷贝过哪些文件,为了保证拍出来的照片都保存下来,只能是对存储卡中拍摄出来的照片不加分辨的全部拷贝,然后放到一个文件夹中;结果导致的是文件夹中的拷贝照片越积越多,给照片筛选造成困难;
②摄影师为了保证有合格照片不可避免地进行连拍;连拍造成的照片冗余,对筛选照片同样造成困难;这种冗余不是照片拷贝时可以避免的,因此属于不可避免的冗余;
③无法准确回忆已经打印过的照片;因为打印文件夹中的文件会被自动移除,为了能保证拍出来的合格照片都被打印出来,我们需要记住打印到什么位置了。我们可能本来是计划严格按照时间顺序进行照片打印的,但是有时会因为有人催促或其他因素导致打印顺序发生错乱;此时我们难以准确记忆我们究竟打印到哪一张照片以及打印了多少张;
解决之道:
针对上述问题,仔细思考便能发现解决之道:
针对①,可以直接通过程序判断两张图片的修改时间是否相同;相同可以认定两张图片就是一模一样的了,此时我们就可以直接将这张重复图片给删除掉;
针对②,连拍对应的照片本质属性就是修改时间上间隔极小;我们可以将图片间隔时间小于特定值的归位一类,如重命名成"1-1", "1-2", 如此在整理时便可以直接凭借文件名前缀快速进行照片筛选;
针对③,我们可以利用程序监测打印文件夹;严格的记录曾经拖动到打印文件夹中的文件以及次数。当然需要解决一个问题就是,在②时我们利用了重命名的机制,而记录照片打印情况,就是用"文件名: 次数"的形式。重命名可能导致这个Key发生改变,因此在进行重命名时,对于已经重命名了的文件,我们不能对它们进行重命名。
如下就是针对上面诸多情况编写的简易GUI程序,没有添加过多功能,只能保证基础使用:
# -*- coding: utf-8 -*-
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from threading import Thread
from tkinter import filedialog, messagebox
from tkinter.ttk import Treeview, Combobox, Entry
from time import sleep
import tkinter as tk
import json
import os
import re
FONT = ("宋体", 14, "bold")
CURRENT_PATH = os.path.dirname(os.path.abspath(__file__))
class WinGUI(tk.Tk):
def __init__(self):
super().__init__()
self.selected_option: tk.IntVar = tk.IntVar()
self.__win()
self.rename_tip_label: tk.Label = self.__set_rename_entry_tip()
self.monitor_tip_label: tk.Label = self.__set_monitor_entry_tip()
self.rename_entry: tk.Entry = self.__set_rename_entry_dir()
self.monitor_entry: tk.Entry = self.__set_monitor_entry_dir()
self.ask_dir_btn1: tk.Button = self.__set_rename_btn_dir()
self.ask_dir_btn2: tk.Button = self.__set_monitor_btn_dir()
self.link_dir_btn1: tk.Button = self.__set_btn_link_dir1()
self.link_dir_btn2: tk.Button = self.__set_btn_link_dir2()
self.suffix_label: tk.Label = self.__set_label_suffix()
self.suffix_combobox: Combobox = self.__set_suffix_combobox()
self.interval_label: tk.Label = self.__set_label_interval()
self.time_interval_entry: tk.Entry = self.__set_entry_time_interval()
self.monitor_treeview: MonitorTreeview = self.__set_monitor_treeview()
self.rename_btn: tk.Button = self.__set_rename_btn()
self.monitor_btn: tk.Button = self.__set_monitor_btn()
def __win(self):
self.title("照片打印监测")
# 设置窗口大小、居中
width, height = 390, 230
screenwidth = self.winfo_screenwidth()
screenheight = self.winfo_screenheight()
geometry = '%dx%d+%d+%d' % (width, height, (screenwidth - width) / 2, (screenheight - height) / 2)
self.geometry(geometry)
self.attributes('-topmost', 1)
self.resizable(False, False)
def __set_rename_entry_tip(self):
rename_entry_tip_label = tk.Label(self, text="命名区:", font=FONT)
rename_entry_tip_label.place(x=10, y=3)
return rename_entry_tip_label
def __set_monitor_entry_tip(self):
monitor_entry_tip = tk.Label(self, text="监测区:", font=FONT)
monitor_entry_tip.place(x=10, y=38)
return monitor_entry_tip
def __set_rename_entry_dir(self):
rename_entry = Entry(self)
rename_entry.place(x=83, y=0, height=30, width=196)
return rename_entry
def __set_monitor_entry_dir(self):
monitor_entry = Entry(self)
monitor_entry.place(x=83, y=35, height=30, width=196)
return monitor_entry
def __set_rename_btn_dir(self):
button_dir = tk.Button(self, text="浏览", bg="wheat", font=('宋体', 14, "bold"))
button_dir.place(x=274, y=0, height=30, width=59)
return button_dir
def __set_monitor_btn_dir(self):
button_dir = tk.Button(self, text="浏览", bg="wheat", font=('宋体', 14, "bold"))
button_dir.place(x=274, y=35, height=30, width=59)
return button_dir
def __set_btn_link_dir1(self):
link_btn = tk.Button(self, text='>>', bg='gold', font=('宋体', 15))
link_btn.place(x=332, y=0, height=30, width=30)
return link_btn
def __set_btn_link_dir2(self):
link_btn = tk.Button(self, text='>>', bg='gold', font=('宋体', 15))
link_btn.place(x=332, y=35, height=30, width=30)
return link_btn
def __set_label_suffix(self):
label_suffix = tk.Label(self, text="后缀名:", font=FONT)
label_suffix.place(x=10, y=112, height=30)
return label_suffix
def __set_label_interval(self):
label_interval = tk.Label(self, text='合并域:', font=FONT)
label_interval.place(x=10, y=70, height=35)
return label_interval
def __set_suffix_combobox(self):
combobox = Combobox(self, values=['.jpg', '.png', '.jpeg'])
combobox.insert(0, '.jpg')
combobox.state(['readonly'])
combobox.place(x=83, y=110, width=78, height=30)
return combobox
def __set_entry_time_interval(self):
entry_time_interval = Entry(self, width=5)
entry_time_interval.place(x=83, y=73, width=78, height=30)
return entry_time_interval
def __set_monitor_treeview(self):
columns = ("主编号", "子编号", "总次数")
column_width = (60, 105, 60)
treeview = MonitorTreeview(self, columns=columns, show='headings')
for column, width in zip(columns, column_width):
treeview.column(column, width=width, anchor="center")
treeview.heading(column, text=column)
treeview.place(x=166, y=69, height=162, width=225)
return treeview
def __set_rename_btn(self):
button_rename = tk.Button(self, text="命名", bg='#A6D2FF', font=('宋体', 18, 'bold'))
button_rename.place(x=10, y=165, height=45, width=76)
return button_rename
def __set_monitor_btn(self):
button_monitor = tk.Button(self, text="监测", bg='#27CC58', font=('宋体', 18, 'bold'))
button_monitor.place(x=86, y=165, height=45, width=74)
return button_monitor
class MonitorTreeview(Treeview):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.monitor_info: dict | None = None
self.undo_stack: list = []
self.toplevel_alive: bool = False
def bind_event(self, monitor_info):
self.monitor_info = monitor_info
self.bind("<Double-1>", self.__modify)
self.bind("<Delete>", self.__delete)
self.bind("<Control-z>", self.__undo)
def refresh(self):
sorted_keys = sorted(self.monitor_info.keys(), key=lambda x: int(x) if x.isdigit() else ord(x[0]))
self.delete(*self.get_children())
for key in sorted_keys:
if str(key).isdigit():
data = (key, self.monitor_info[key], sum(self.monitor_info[key].values()))
else:
data = (key, 0, self.monitor_info[key])
self.insert('', tk.END, values=data)
def __modify(self, event):
def save():
try:
data: dict = eval(text_widget.get("1.0", tk.END).strip())
is_valid_format = all(
isinstance(k, str) and k.isdigit() and isinstance(v, int)
for k, v in data.items()
)
except Exception:
is_valid_format = False
if is_valid_format:
self.undo_stack.append((values[0], self.monitor_info[values[0]]))
self.monitor_info[values[0]] = data
self.refresh()
else:
messagebox.showerror("错误", "格式错误, 保存失败!")
self.__destroy_toplevel(child_window)
selection = self.selection()
if len(selection) == 0:
return
if self.toplevel_alive:
return
values = self.item(selection[0], 'values')
if values[1] == '0':
return
self.toplevel_alive = True
child_window = tk.Toplevel()
child_window.protocol('WM_DELETE_WINDOW', lambda: self.__destroy_toplevel(child_window))
child_window.attributes('-topmost', 1)
child_window.title(f"主编号: {values[0]}")
child_window.geometry(f"220x100+{event.x_root-110}+{event.y_root-50}")
text_widget = tk.Text(child_window, font=("Calibri", 14))
text_widget.place(width=160, height=100)
save_btn = tk.Button(child_window, text="保存", command=save, bg="#26C755", font=FONT)
save_btn.place(x=160, y=30, width=60, height=30)
text_widget.insert(tk.END, json.dumps(eval(values[1]), indent=2))
def __destroy_toplevel(self, top_window: tk.Toplevel):
self.toplevel_alive = False
top_window.destroy()
def __delete(self, _):
selection = self.selection()
if len(selection) == 0:
return
for item in selection:
values = self.item(item, 'values')
self.undo_stack.append((values[0], self.monitor_info[values[0]]))
self.monitor_info.pop(values[0])
self.delete(*selection)
def __undo(self, _):
if self.toplevel_alive or len(self.undo_stack) == 0:
return
key, value = self.undo_stack.pop()
self.monitor_info[key] = value
self.refresh()
class Control(WinGUI):
def __init__(self):
super().__init__()
self.rename_initial_dir: str = ''
self.monitor_initial_dir: str = ''
self.extension: str = '.jpg'
self.monitor_state: enumerate[0, 1] = 0
self.monitor_info: dict = self.load_disk_info()
self.id_table: dict = {
0: [self.rename_entry, self.rename_initial_dir],
1: [self.monitor_entry, self.monitor_initial_dir],
}
self.__event_bind()
def __event_bind(self) -> None:
self.protocol('WM_DELETE_WINDOW', lambda: self.close_save())
self.monitor_treeview.bind_event(self.monitor_info)
self.ask_dir_btn1.config(command=lambda: self.choose_directory(0))
self.ask_dir_btn2.config(command=lambda: self.choose_directory(1))
self.link_dir_btn1.config(command=lambda: self.link_to_dir(0))
self.link_dir_btn2.config(command=lambda: self.link_to_dir(1))
self.rename_btn.config(command=self.start_rename)
self.monitor_btn.config(command=self.start_refresh_monitor)
def link_to_dir(self, entry_id) -> None:
directory = self.id_table[entry_id][0].get()
if not os.path.isdir(directory):
return
self.after(3, lambda: os.startfile(directory))
@staticmethod
def get_files(directory: str) -> list:
with os.scandir(directory) as entries:
return [entry.name for entry in entries if entry.is_file()]
def choose_directory(self, entry_id) -> None:
entry, initial_dir = self.id_table[entry_id]
folder_selected = filedialog.askdirectory(initialdir=initial_dir)
if not folder_selected:
return
self.id_table[entry_id][1] = folder_selected
entry.delete(0, tk.END)
entry.insert(0, folder_selected)
def get_same_suffix_interval(self) -> float:
try:
time_interval = float(self.time_interval_entry.get())
if time_interval < 0:
raise ValueError
return time_interval
except ValueError:
self.time_interval_entry.delete(0, tk.END)
self.time_interval_entry.insert(0, '5')
return 5.0
def prefix_suffix_rename(self, work_path: str) -> None:
# 初始化变量
os.chdir(work_path)
folder_files, prefix = self.get_required_info()
suffix: int = 0
prev_time: float = 0.0
time_interval = self.get_same_suffix_interval()
for old_name in folder_files:
current_time = os.path.getmtime(old_name)
if (current_time - prev_time) > time_interval:
prefix += 1
suffix = 1
else:
suffix += 1
new_name = f"{prefix}-{suffix}{self.extension}"
prev_time = current_time
try:
os.rename(old_name, new_name)
except (PermissionError, OSError):
messagebox.showerror(f'命名时出现错误,\n请尝试重新命名')
os.chdir(CURRENT_PATH)
return
os.chdir(CURRENT_PATH)
messagebox.showinfo("提示", "重命名成功!")
def get_required_info(self) -> tuple:
# return: required_rename_files + have_renamed_max_num
files: list = self.get_files(self.rename_entry.get())
prev_time: float = 0.0
renamed_max_num: int = 0
required_rename_files: list = []
for file in files:
current_time = os.path.getmtime(file)
if current_time == prev_time:
# 对重复文件进行删除
os.remove(file)
elif re.match('\d+-\d+', file):
renamed_max_num = max(renamed_max_num, int(file.split('-')[0]))
elif re.match('.*\.(jpg|png|jpeg)', file, re.I):
required_rename_files.append(file)
prev_time = current_time
required_rename_files.sort(key=lambda x: os.path.getmtime(x))
return required_rename_files, renamed_max_num
def get_proper_director(self, enter_id) -> str:
director = self.id_table[enter_id][0].get()
if director == '':
messagebox.showinfo('提示', '请先选择文件夹!')
return
elif not os.path.isdir(director):
messagebox.showerror('错误', '文件夹路径不存在,\n请重新选择文件夹!')
return
else:
return director
def start_rename(self) -> None:
directory = self.get_proper_director(0)
if directory is None:
return
self.extension = self.suffix_combobox.get()
Thread(target=self.prefix_suffix_rename, args=(directory, ), daemon=True).start()
def start_refresh_monitor(self) -> None:
if self.monitor_state == 1:
Thread(target=self.monitor_treeview.refresh, daemon=True).start()
else:
directory = self.get_proper_director(1)
if directory is None:
return
self.monitor_btn.config(text="刷新")
Thread(target=self.__monitor, args=(directory, ), daemon=True).start()
def __monitor(self, directory):
self.monitor_state = 1
event_handler = MonitorHandler(directory, self.monitor_info)
observer = Observer()
observer.schedule(event_handler, directory, recursive=True)
observer.start()
try:
while self.monitor_state:
sleep(3)
except KeyboardInterrupt:
observer.stop()
observer.join()
def close_save(self):
with open('basic_data.json', 'w', encoding='utf8') as f:
json.dump({
'monitor_dir': self.monitor_entry.get(),
'rename_dir': self.rename_entry.get(),
'time_interval': self.time_interval_entry.get(),
'monitor_info': self.monitor_info
}, f, ensure_ascii=False, indent=4
)
self.monitor_state = 0
self.destroy()
@staticmethod
def compare_key(key):
try:
prefix, suffix = key.split('-')
return int(prefix), int(suffix)
except ValueError:
return
def load_disk_info(self) -> dict:
# 直接载入数据了
if not os.path.exists("basic_data.json"):
self.monitor_info = dict()
return
with open("basic_data.json", "r", encoding='utf8') as f:
disk_info = json.load(f)
self.monitor_entry.insert(0, disk_info['monitor_dir'])
self.rename_entry.insert(0, disk_info['rename_dir'])
self.time_interval_entry.insert(0, disk_info['time_interval'])
return disk_info['monitor_info']
class MonitorHandler(FileSystemEventHandler):
def __init__(self, folder, monitor_info):
# monitor_info形如: {1: {1: 1, 2: 2}}
self.folder = folder
self.monitor_info = monitor_info
def on_created(self, event):
if event.is_directory:
return
file_name = os.path.basename(event.src_path)
result = re.match(r'(\d+)-(\d+)\.(jpg|png|jpeg)', file_name, re.I)
if result:
prefix, suffix, _ = result.groups()
if prefix not in self.monitor_info:
self.monitor_info[prefix] = {}
if suffix not in self.monitor_info[prefix]:
self.monitor_info[prefix][suffix] = 0
self.monitor_info[prefix][suffix] += 1
else:
if file_name not in self.monitor_info:
self.monitor_info[file_name] = 0
self.monitor_info[file_name] += 1
if __name__ == '__main__':
gui = Control()
gui.mainloop()
基础界面:
程序使用方法:
①命名区就是指从存储卡中拷贝出来的照片存储的文件夹;
②监测区就是拍马打印照片时监测的文件夹;
③合并域就是指两张照片修改时间间隔小于多少秒时,重命名的前缀相同,归位一类。;
④后缀名就是指重命名后图片的后缀,限定了只能选择图片文件"jpg|png|jpeg";
按下"命名"按钮,程序会自动将命名区中的重复图片删除,同时获取没有重命名过的图片以及已经命名图片的最大序号,基于最大序号对这些没有重命名过的图片进行重命名;
按下"监测"按钮,按钮上的文字会变成"刷新",同时程序开始对监测区进行监测;任何移动到多监测区的文件都会被记录;监测数据会显示在右侧表格中,但并不会实时刷新,需要手动按原来的"监测"按钮(后面变成"刷新"按钮)进行数据刷新;