[Python] 纯文本查看 复制代码
import sys
import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import cv2
import numpy as np
import shutil
import threading
import urllib.request
import traceback
import gc
import time
# 版本信息
APP_VERSION = "Version0.1" # 更新版本号
APP_NAME = "学籍照片批处理工具"
# 打包后资源文件路径处理
def resource_path(relative_path):
"""获取打包后资源的绝对路径"""
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
class PhotoProcessorApp:
def __init__(self, root):
self.root = root
self.root.title(f"{APP_NAME}")
self.root.geometry("1100x600")
self.root.resizable(True, True)
# 设置图标
try:
icon_path = resource_path("icon.ico")
if os.path.exists(icon_path):
self.root.iconbitmap(icon_path)
except:
pass
# 初始化变量
self.input_folder = tk.StringVar()
self.output_folder = tk.StringVar()
self.target_width = tk.IntVar(value=413)
self.target_height = tk.IntVar(value=579)
self.extend_ratio = tk.DoubleVar(value=2.0)
self.output_format = tk.StringVar(value="JPG")
self.limit_file_size = tk.BooleanVar(value=False)
self.max_file_size_kb = tk.IntVar(value=50)
self.is_processing = False
self.total_files = 0
self.processed_files = 0
# 在类初始化中添加可配置的阈值
self.small_face_threshold = 0.05 # 小于5%视为小人像
self.large_face_threshold = 0.3 # 大于30%视为大人像
# 性能优化相关变量
self.log_queue = []
self.log_batch_size = 10
self.log_counter = 0
self.cancel_processing = False
self._last_percentage = -1
self.setup_fonts()
self.setup_ui()
# 初始化人脸检测器
self.face_cascade = self.get_face_detector()
if self.face_cascade is None or self.face_cascade.empty():
messagebox.showwarning("警告", "人脸检测器加载失败,部分功能可能受限")
def setup_fonts(self):
"""设置中文字体"""
chinese_fonts = ["Microsoft YaHei", "SimHei", "SimSun", "KaiTi", "FangSong"]
self.title_font = None
self.normal_font = None
test_label = ttk.Label(self.root, text="测试")
for font in chinese_fonts:
try:
test_label.config(font=(font, 12))
self.root.update()
self.title_font = (font, 16, "bold")
self.normal_font = (font, 9)
break
except:
continue
if self.title_font is None:
self.title_font = ("TkDefaultFont", 16, "bold")
self.normal_font = ("TkDefaultFont", 9)
test_label.destroy()
def setup_ui(self):
# 创建样式
style = ttk.Style()
# 修改标题字体样式
style.configure("TLabel", font=('Microsoft YaHei', 10))
style.configure("Header.TLabel", font=('Microsoft YaHei', 16, 'bold'), foreground='#333333')
# 为按钮添加更明显的底色样式
style.configure("Light.TButton", font=('Microsoft YaHei', 10),
padding=(10, 5), relief="flat")
# 使用ttk.Style的map方法为不同状态设置不同背景色
style.map("Light.TButton",
background=[("!disabled", "#e0e0e0"), ("active", "#d0d0d0")],
foreground=[("!disabled", "#000000")])
# 创建主框架
main_frame = ttk.Frame(self.root, padding="15")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
# 配置主框架的列权重 - 改为2列布局,左侧控制面板,右侧日志面板
main_frame.columnconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1) # 右侧日志框占据一半空间
# 标题
title_label = ttk.Label(main_frame, text=APP_NAME, style="Header.TLabel")
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 20))
# 文件夹选择部分 - 使用更紧凑的布局
folder_frame = ttk.LabelFrame(main_frame, text="文件路径", padding="10")
folder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
folder_frame.columnconfigure(1, weight=1)
# 输入文件夹选择
ttk.Label(folder_frame, text="输入文件夹:").grid(row=0, column=0, sticky=tk.W, pady=(5, 5))
ttk.Entry(folder_frame, textvariable=self.input_folder).grid(row=0, column=1, sticky=(tk.W, tk.E),
pady=(5, 5), padx=(5, 5))
ttk.Button(folder_frame, text="浏览", command=self.browse_input_folder, style="Light.TButton").grid(row=0, column=2, pady=(5, 5))
# 输出文件夹选择
ttk.Label(folder_frame, text="输出文件夹:").grid(row=1, column=0, sticky=tk.W, pady=(5, 5))
ttk.Entry(folder_frame, textvariable=self.output_folder).grid(row=1, column=1, sticky=(tk.W, tk.E),
pady=(5, 5), padx=(5, 5))
ttk.Button(folder_frame, text="浏览", command=self.browse_output_folder, style="Light.TButton").grid(row=1, column=2, pady=(5, 5))
# 参数设置 - 使用网格布局使参数更整齐
params_frame = ttk.LabelFrame(main_frame, text="处理参数", padding="10")
params_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=10)
# 配置参数框架的列
for i in range(4):
params_frame.columnconfigure(i, weight=1 if i in (1, 3) else 0)
# 输出尺寸 - 垂直布局
ttk.Label(params_frame, text="输出尺寸:").grid(row=0, column=0, sticky=tk.W, pady=(0, 10))
size_frame = ttk.Frame(params_frame)
size_frame.grid(row=0, column=1, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10))
width_frame = ttk.Frame(size_frame)
width_frame.pack(side=tk.LEFT, padx=(0, 20))
ttk.Label(width_frame, text="宽度: ").pack(side=tk.LEFT, padx=(0, 5))
ttk.Entry(width_frame, textvariable=self.target_width, width=8).pack(side=tk.LEFT)
ttk.Label(width_frame, text="像素").pack(side=tk.LEFT, padx=(5, 0))
height_frame = ttk.Frame(size_frame)
height_frame.pack(side=tk.LEFT)
ttk.Label(height_frame, text="高度: ").pack(side=tk.LEFT, padx=(0, 5))
ttk.Entry(height_frame, textvariable=self.target_height, width=8).pack(side=tk.LEFT)
ttk.Label(height_frame, text="像素").pack(side=tk.LEFT, padx=(5, 0))
# 扩展比例 - 垂直布局(输出尺寸下方)
ttk.Label(params_frame, text="扩展比例:").grid(row=1, column=0, sticky=tk.W, pady=(0, 10))
scale_frame = ttk.Frame(params_frame)
scale_frame.grid(row=1, column=1, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10))
ttk.Entry(scale_frame, textvariable=self.extend_ratio, width=10).pack(side=tk.LEFT)
ttk.Label(scale_frame, text="(推荐值: 1.5-3.0)").pack(side=tk.LEFT, padx=(10, 0))
# 输出格式 - 垂直布局
ttk.Label(params_frame, text="输出格式:").grid(row=2, column=0, sticky=tk.W, pady=(0, 10))
format_frame = ttk.Frame(params_frame)
format_frame.grid(row=2, column=1, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10))
format_combo = ttk.Combobox(format_frame, textvariable=self.output_format, values=["JPG", "PNG", "BMP"],
state="readonly", width=10, font=('Microsoft YaHei', 9))
format_combo.pack(side=tk.LEFT)
ttk.Label(format_frame, text="(选择输出图片的格式)").pack(side=tk.LEFT, padx=(10, 0))
# 文件大小限制 - 垂直布局(输出格式下方)
ttk.Label(params_frame, text="大小限制:").grid(row=3, column=0, sticky=tk.W, pady=(0, 10))
size_limit_frame = ttk.Frame(params_frame)
size_limit_frame.grid(row=3, column=1, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10))
ttk.Checkbutton(size_limit_frame, text="启用", variable=self.limit_file_size).pack(side=tk.LEFT)
size_entry_frame = ttk.Frame(size_limit_frame)
size_entry_frame.pack(side=tk.LEFT, padx=(10, 0))
ttk.Entry(size_entry_frame, textvariable=self.max_file_size_kb, width=8).pack(side=tk.LEFT)
ttk.Label(size_entry_frame, text="KB").pack(side=tk.LEFT, padx=(5, 0))
# 处理按钮和取消按钮 - 居中布局
button_frame = ttk.Frame(main_frame)
button_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=15)
button_frame.grid_columnconfigure(0, weight=1)
button_frame.grid_columnconfigure(1, weight=0)
button_frame.grid_columnconfigure(2, weight=0)
button_frame.grid_columnconfigure(3, weight=1)
self.process_btn = ttk.Button(button_frame, text="开始处理", command=self.start_processing, style="Light.TButton")
self.process_btn.grid(row=0, column=1, padx=(0, 10))
self.cancel_btn = ttk.Button(button_frame, text="取消处理", command=self.cancel_processing_func, state="disabled", style="Light.TButton")
self.cancel_btn.grid(row=0, column=2)
# 进度条 - 改进布局
progress_frame = ttk.Frame(main_frame)
progress_frame.grid(row=4, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
progress_frame.columnconfigure(0, weight=1)
self.progress = ttk.Progressbar(progress_frame, mode='determinate')
self.progress.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 10))
self.progress_label = ttk.Label(progress_frame, text="0%", width=5)
self.progress_label.grid(row=0, column=1, sticky=tk.W)
# 日志文本框 - 移至右侧并占据整个右侧窗口
log_frame = ttk.LabelFrame(main_frame, text="处理日志", padding="8")
log_frame.grid(row=1, column=1, rowspan=4, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10), padx=(10, 0))
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
text_scroll = ttk.Scrollbar(log_frame)
text_scroll.grid(row=0, column=1, sticky=(tk.N, tk.S))
self.log_text = tk.Text(log_frame, yscrollcommand=text_scroll.set, font=('Microsoft YaHei', 9))
self.log_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
text_scroll.config(command=self.log_text.yview)
# 配置主框架各行权重
main_frame.rowconfigure(4, weight=1) # 确保左侧面板可适当拉伸
main_frame.rowconfigure(1, weight=0) # 控制行权重
# 底部行 - 放置准备标签和版本号
bottom_frame = ttk.Frame(main_frame)
bottom_frame.grid(row=5, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
bottom_frame.columnconfigure(0, weight=1)
# 状态标签 - 加粗显示
self.status_label = ttk.Label(bottom_frame, text="准备就绪", font=('Microsoft YaHei', 10, 'bold'))
self.status_label.grid(row=0, column=0, sticky=tk.W, padx=(0, 10))
# 版本号放在右下角
version_label = ttk.Label(bottom_frame, text=APP_VERSION, font=('Microsoft YaHei', 8))
version_label.grid(row=0, column=1, sticky=tk.E, padx=(0, 10))
def browse_input_folder(self):
folder = filedialog.askdirectory(title="选择输入文件夹")
if folder:
self.input_folder.set(folder)
def browse_output_folder(self):
folder = filedialog.askdirectory(title="选择输出文件夹")
if folder:
self.output_folder.set(folder)
def log(self, message):
"""批量处理日志更新,减少UI刷新频率"""
self.log_queue.append(message)
self.log_counter += 1
# 批量更新或重要消息立即更新
if (self.log_counter >= self.log_batch_size or
any(keyword in message for keyword in ["错误", "失败", "警告", "完成", "取消", "超时"])):
self.flush_log_queue()
def flush_log_queue(self):
"""将积压的日志一次性更新到UI"""
if not self.log_queue:
return
# 一次性插入所有日志
log_text = "\n".join(self.log_queue) + "\n"
self.log_text.insert(tk.END, log_text)
self.log_text.see(tk.END)
# 清空队列和计数器
self.log_queue = []
self.log_counter = 0
# 只在这里更新UI
self.root.update_idletasks()
def update_status(self, message):
self.status_label.config(text=message)
# 状态更新不频繁,可以直接更新UI
self.root.update_idletasks()
def update_progress(self, value):
"""批量更新进度,减少UI刷新"""
self.progress['value'] = value
# 只在进度变化较大时更新百分比
percentage = int((value / self.total_files) * 100) if self.total_files > 0 else 0
current_percentage = getattr(self, '_last_percentage', -1)
if percentage != current_percentage or value == self.total_files or value == 0:
self.progress_label.config(text=f"{percentage}%")
self._last_percentage = percentage
# 只在百分比变化时或处理完成时更新UI
if percentage % 10 == 0 or value == self.total_files or value == 0:
self.flush_log_queue() # 顺便刷新日志
def cancel_processing_func(self):
"""取消处理"""
self.cancel_processing = True
self.cancel_btn.config(state="disabled")
self.log("正在取消处理,请等待...")
self.flush_log_queue()
def start_processing(self):
if self.is_processing:
return
if not self.input_folder.get():
messagebox.showerror("错误", "请选择输入文件夹")
return
if not self.output_folder.get():
messagebox.showerror("错误", "请选择输出文件夹")
return
if not os.path.exists(self.input_folder.get()):
messagebox.showerror("错误", "输入文件夹不存在")
return
if self.limit_file_size.get():
if self.max_file_size_kb.get() <= 0:
messagebox.showerror("错误", "文件大小限制必须大于0KB")
return
if self.output_format.get() == "BMP":
messagebox.showwarning("警告", "BMP格式无法压缩")
# 开始处理
self.is_processing = True
self.cancel_processing = False # 重置取消标志
self.process_btn.config(state="disabled")
self.cancel_btn.config(state="normal") # 启用取消按钮
self.progress['value'] = 0
self.progress_label.config(text="0%")
self.log_text.delete(1.0, tk.END)
self._last_percentage = -1 # 重置进度百分比
thread = threading.Thread(target=self.process_images)
thread.daemon = True
thread.start()
def get_face_detector(self):
cascade_paths = []
try:
builtin_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
if os.path.exists(builtin_path):
cascade_paths.append(builtin_path)
except:
pass
try:
resource_cascade_path = resource_path('haarcascade_frontalface_default.xml')
if os.path.exists(resource_cascade_path):
cascade_paths.append(resource_cascade_path)
except:
pass
try:
current_dir_path = "haarcascade_frontalface_default.xml"
if os.path.exists(current_dir_path):
cascade_paths.append(current_dir_path)
except:
pass
for cascade_path in cascade_paths:
try:
face_cascade = cv2.CascadeClassifier(cascade_path)
if not face_cascade.empty():
self.log(f"成功加载人脸检测器: {cascade_path}")
return face_cascade
except Exception as e:
self.log(f"加载人脸检测器失败 {cascade_path}: {e}")
try:
self.log("尝试下载人脸检测模型...")
url = "https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml"
download_path = resource_path("haarcascade_frontalface_default.xml")
if not os.path.exists(download_path):
urllib.request.urlretrieve(url, download_path)
self.log("人脸检测模型下载完成")
face_cascade = cv2.CascadeClassifier(download_path)
if not face_cascade.empty():
self.log("使用下载的人脸检测模型")
return face_cascade
except Exception as e:
self.log(f"下载人脸检测模型失败: {e}")
self.log("警告: 无法加载任何人脸检测器")
return None
def get_background_color(self, image):
"""获取图像背景颜色,兼容彩色和灰度图像"""
h, w = image.shape[:2]
# 检查图像是彩色还是灰度
is_color = len(image.shape) == 3 and image.shape[2] == 3
# 确保有足够的边缘区域
edge_size = min(5, h, w)
try:
# 获取各边缘区域
top_edge = image[0:edge_size, :]
bottom_edge = image[h - edge_size:h, :]
left_edge = image[:, 0:edge_size]
right_edge = image[:, w - edge_size:w]
# 根据图像类型处理边缘
if is_color:
# 彩色图像 - 重塑为 (像素数, 3)
edges_list = []
if top_edge.size > 0:
top_flat = top_edge.reshape(-1, 3)
edges_list.append(top_flat)
if bottom_edge.size > 0:
bottom_flat = bottom_edge.reshape(-1, 3)
edges_list.append(bottom_flat)
if left_edge.size > 0:
left_flat = left_edge.reshape(-1, 3)
edges_list.append(left_flat)
if right_edge.size > 0:
right_flat = right_edge.reshape(-1, 3)
edges_list.append(right_flat)
else:
# 灰度图像 - 重塑为 (像素数, 1)
edges_list = []
if top_edge.size > 0:
top_flat = top_edge.reshape(-1, 1)
edges_list.append(top_flat)
if bottom_edge.size > 0:
bottom_flat = bottom_edge.reshape(-1, 1)
edges_list.append(bottom_flat)
if left_edge.size > 0:
left_flat = left_edge.reshape(-1, 1)
edges_list.append(left_flat)
if right_edge.size > 0:
right_flat = right_edge.reshape(-1, 1)
edges_list.append(right_flat)
# 检查是否有有效的边缘数据
if not edges_list:
# 返回默认背景色(白色或灰色)
return np.array([255, 255, 255], dtype=np.uint8) if is_color else np.array([255], dtype=np.uint8)
# 合并所有边缘像素
edges = np.vstack(edges_list)
# 计算平均颜色
avg_color = np.mean(edges, axis=0)
# 确保返回正确的格式
if is_color:
return avg_color.astype(np.uint8)
else:
# 灰度图像返回单值,但转换为三通道用于彩色背景
gray_value = avg_color.astype(np.uint8)[0]
return np.array([gray_value, gray_value, gray_value], dtype=np.uint8)
except Exception as e:
self.log(f"获取背景颜色时出错: {e}, 使用默认白色背景")
return np.array([255, 255, 255], dtype=np.uint8)
def resize_to_target(self, image, face_rect, target_size):
"""根据人脸大小智能调整图像到目标尺寸"""
target_width, target_height = target_size
h, w = image.shape[:2]
# 计算人脸在图像中的占比
x, y, face_w, face_h = face_rect
face_area = face_w * face_h
image_area = w * h
face_ratio = face_area / image_area
self.log(f"人脸占比: {face_ratio:.3f} ({face_w}x{face_h} / {w}x{h})")
# 根据人脸占比决定处理策略
if face_ratio < 0.05: # 人脸很小,需要放大
# 计算放大比例,使人脸大小合适
# 目标人脸大小约为图像的15-20%
target_face_ratio = 0.18
scale = (target_face_ratio / face_ratio) ** 0.5 # 开方使缩放更温和
# 限制最大缩放倍数
scale = min(scale, 3.0) # 最多放大3倍
new_w = int(w * scale)
new_h = int(h * scale)
# 使用高质量的插值方法放大图像
enlarged = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_CUBIC)
# 计算以人脸为中心的新裁剪区域
face_center_x = int((x + face_w / 2) * scale)
face_center_y = int((y + face_h / 2) * scale)
# 从放大后的图像以人脸为中心裁剪目标尺寸
start_x = max(0, face_center_x - target_width // 2)
start_y = max(0, face_center_y - target_height // 2)
end_x = start_x + target_width
end_y = start_y + target_height
# 确保不越界
if end_x > new_w:
end_x = new_w
start_x = end_x - target_width
if end_y > new_h:
end_y = new_h
start_y = end_y - target_height
result = enlarged[start_y:end_y, start_x:end_x]
self.log(f"小人像: 放大 {scale:.2f} 倍并以人脸为中心裁剪")
elif face_ratio > 0.3: # 人脸很大,可能需要缩小
# 计算缩小比例
scale = min(target_width / w, target_height / h)
new_w = int(w * scale)
new_h = int(h * scale)
# 缩小图像
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
# 使用背景色填充
bg_color = self.get_background_color(image)
result = np.full((target_height, target_width, 3), bg_color, dtype=np.uint8)
y_offset = (target_height - new_h) // 2
x_offset = (target_width - new_w) // 2
result[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized
self.log(f"大人像: 缩小 {scale:.2f} 倍并填充背景")
else: # 人脸大小适中,使用标准处理
scale = min(target_width / w, target_height / h)
new_w = int(w * scale)
new_h = int(h * scale)
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
bg_color = self.get_background_color(image)
result = np.full((target_height, target_width, 3), bg_color, dtype=np.uint8)
y_offset = (target_height - new_h) // 2
x_offset = (target_width - new_w) // 2
result[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized
self.log(f"标准处理: 调整尺寸并居中")
return result
def get_original_file_size(self, file_path):
try:
return os.path.getsize(file_path) / 1024
except:
return None
def compress_to_target_size(self, image, target_size_kb, output_format, original_size_kb=None):
max_size_bytes = target_size_kb * 1024
# 如果原始文件已经小于目标大小,直接使用高质量编码
if original_size_kb is not None and original_size_kb <= target_size_kb:
self.log(f"原始文件大小 {original_size_kb:.1f}KB 已满足要求,使用高质量编码")
if output_format == "JPG":
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 95]
success, encoded_image = cv2.imencode('.jpg', image, encode_param)
if success:
return encoded_image, len(encoded_image) / 1024, 95, False
elif output_format == "PNG":
encode_param = [int(cv2.IMWRITE_PNG_COMPRESSION), 1]
success, encoded_image = cv2.imencode('.png', image, encode_param)
if success:
return encoded_image, len(encoded_image) / 1024, 1, False
if output_format == "JPG":
qualities = [95, 90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10]
for quality in qualities:
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
success, encoded_image = cv2.imencode('.jpg', image, encode_param)
if success and len(encoded_image) <= max_size_bytes:
file_size_kb = len(encoded_image) / 1024
self.log(f"JPG质量{quality}%,文件大小: {file_size_kb:.1f}KB")
return encoded_image, file_size_kb, quality, True
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 10]
success, encoded_image = cv2.imencode('.jpg', image, encode_param)
file_size_kb = len(encoded_image) / 1024
self.log(f"使用最低质量10%,文件大小: {file_size_kb:.1f}KB")
return encoded_image, file_size_kb, 10, True
elif output_format == "PNG":
compression_levels = [1, 3, 5, 7, 9]
for level in compression_levels:
encode_param = [int(cv2.IMWRITE_PNG_COMPRESSION), level]
success, encoded_image = cv2.imencode('.png', image, encode_param)
if success and len(encoded_image) <= max_size_bytes:
file_size_kb = len(encoded_image) / 1024
self.log(f"PNG压缩级别{level},文件大小: {file_size_kb:.1f}KB")
return encoded_image, file_size_kb, level, True
encode_param = [int(cv2.IMWRITE_PNG_COMPRESSION), 9]
success, encoded_image = cv2.imencode('.png', image, encode_param)
file_size_kb = len(encoded_image) / 1024
self.log(f"使用最高压缩级别9,文件大小: {file_size_kb:.1f}KB")
return encoded_image, file_size_kb, 9, True
else:
success, encoded_image = cv2.imencode('.bmp', image)
file_size_kb = len(encoded_image) / 1024 if success else 0
self.log(f"BMP格式无法压缩,文件大小: {file_size_kb:.1f}KB")
return encoded_image, file_size_kb, 0, False
def detect_face_and_half_body(self, input_image_path, output_image_path, target_size, extend_ratio, output_format):
try:
# 检查是否取消处理
if self.cancel_processing:
return "cancelled"
original_size_kb = self.get_original_file_size(input_image_path)
if original_size_kb is not None:
self.log(f"原始文件大小: {original_size_kb:.1f}KB")
# 读取图像
with open(input_image_path, 'rb') as f:
img_data = np.frombuffer(f.read(), dtype=np.uint8)
image = cv2.imdecode(img_data, cv2.IMREAD_COLOR)
if image is None:
self.log(f"无法读取图像: {input_image_path}")
return "read_error"
# 记录图像信息
h, w = image.shape[:2]
channels = image.shape[2] if len(image.shape) > 2 else 1
self.log(f"图像尺寸: {w}x{h}, 通道数: {channels}")
# 如果没有人脸检测器,直接调整尺寸
if self.face_cascade is None or self.face_cascade.empty():
self.log(f"无人脸检测器,直接调整尺寸: {os.path.basename(input_image_path)}")
resized_image = self.resize_to_target(image, target_size)
output_ext = self.get_extension(output_format)
if self.limit_file_size.get() and output_format in ["JPG", "PNG"]:
max_size_kb = self.max_file_size_kb.get()
encoded_image, actual_size, quality, was_compressed = self.compress_to_target_size(
resized_image, max_size_kb, output_format, original_size_kb
)
if was_compressed:
self.log(f"文件已压缩,质量级别: {quality},实际大小: {actual_size:.1f}KB")
else:
self.log(f"文件未压缩,使用高质量保存,大小: {actual_size:.1f}KB")
else:
success, encoded_image = self.encode_image(resized_image, output_format, high_quality=True)
if encoded_image is not None:
output_dir = os.path.dirname(output_image_path)
output_name = os.path.splitext(os.path.basename(output_image_path))[0] + output_ext
final_output_path = os.path.join(output_dir, output_name)
with open(final_output_path, 'wb') as f:
f.write(encoded_image)
self.log(f"已调整图像尺寸: {final_output_path}")
return "success_no_face_detector"
else:
return "save_error"
# 创建灰度图像用于人脸检测
gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
self.log(f"处理图像: {os.path.basename(input_image_path)}")
# 检查是否取消处理
if self.cancel_processing:
return "cancelled"
# 使用更严格的人脸检测参数
start_time = time.time()
# 增加minNeighbors以减少误检,调整minSize过滤太小的人脸
faces = self.face_cascade.detectMultiScale(
gray_image,
scaleFactor=1.1,
minNeighbors=7, # 从5增加到7,减少假阳性
minSize=(50, 50), # 从30x30增加到50x50,过滤太小的人脸
flags=cv2.CASCADE_SCALE_IMAGE
)
detection_time = time.time() - start_time
# 添加详细的调试信息
self.log(f"检测参数: minNeighbors=7, minSize=(50,50)")
self.log(f"检测到 {len(faces)} 个人脸,耗时 {detection_time:.2f}秒")
if detection_time > 5:
self.log(f"警告: 人脸检测耗时较长 ({detection_time:.1f}秒)")
if len(faces) == 0:
self.log(f"在 {os.path.basename(input_image_path)} 中未检测到人脸")
return "no_face"
# 改进的人脸选择逻辑
if len(faces) > 1:
self.log(f"检测到 {len(faces)} 个人脸,选择最佳人脸")
# 计算每个人脸的评分
face_scores = []
img_height, img_width = gray_image.shape
for i, (x, y, w, h) in enumerate(faces):
# 1. 面积得分 (越大越好)
area_score = w * h
# 2. 位置得分 (越靠近中心越好)
center_x = x + w / 2
center_y = y + h / 2
distance_from_center = np.sqrt(
(center_x - img_width / 2) ** 2 + (center_y - img_height / 2) ** 2
)
# 距离中心越近,位置得分越高
position_score = 1.0 / (1.0 + distance_from_center / min(img_width, img_height))
# 3. 宽高比得分 (接近标准人脸比例0.8-1.2最好)
aspect_ratio = w / h
if 0.8 <= aspect_ratio <= 1.2: # 标准人脸宽高比范围
aspect_score = 1.0
else:
# 偏离标准比例越远,得分越低
aspect_score = 1.0 / (1.0 + abs(aspect_ratio - 1.0))
# 4. 边界检查得分 (避免选择太靠近边缘的人脸)
margin_ratio = 0.1 # 边界比例
margin_x = img_width * margin_ratio
margin_y = img_height * margin_ratio
if (x > margin_x and y > margin_y and
x + w < img_width - margin_x and
y + h < img_height - margin_y):
boundary_score = 1.0
else:
# 靠近边界的人脸得分降低
boundary_score = 0.5
# 综合得分 (权重可以调整)
total_score = (
area_score * 0.5 + # 面积权重最高
position_score * 1000 * 0.3 + # 位置权重
aspect_score * 1000 * 0.1 + # 宽高比权重
boundary_score * 1000 * 0.1 # 边界权重
)
face_scores.append((total_score, i, (x, y, w, h)))
# 详细的调试信息
self.log(f"人脸 {i + 1}: 位置({x},{y}) 尺寸({w}x{h}) "
f"面积得分{area_score} 位置得分{position_score:.3f} "
f"宽高比{aspect_ratio:.2f}得分{aspect_score:.3f} "
f"边界得分{boundary_score} 总分: {total_score:.2f}")
# 选择得分最高的人脸
best_face = max(face_scores, key=lambda x: x[0])
self.log(f"选择得分最高的人脸 {best_face[1] + 1}, 得分: {best_face[0]:.2f}")
face = best_face[2]
else:
self.log(f"只检测到1个人脸,直接使用")
face = faces[0]
x, y, w, h = face
self.log(f"最终选择的人脸: 位置({x},{y}) 尺寸({w}x{h})")
self.log(f"使用的扩展比例: {extend_ratio}")
center_x = x + w // 2
center_y = y + h // 2
target_width, target_height = target_size
target_aspect_ratio = target_width / target_height
# 重新设计裁剪区域计算逻辑
# 基于人脸尺寸和扩展比例计算初始裁剪区域
base_crop_width = int(w * extend_ratio)
base_crop_height = int(h * extend_ratio)
self.log(f"基础裁剪尺寸: {base_crop_width}x{base_crop_height} (基于扩展比例 {extend_ratio})")
# 确保裁剪区域符合目标宽高比
if base_crop_width / base_crop_height > target_aspect_ratio:
# 太宽了,调整高度
base_crop_height = int(base_crop_width / target_aspect_ratio)
else:
# 太高了,调整宽度
base_crop_width = int(base_crop_height * target_aspect_ratio)
self.log(f"调整后的裁剪尺寸: {base_crop_width}x{base_crop_height} (符合目标宽高比)")
# 确保裁剪区域不小于目标尺寸
crop_width = max(base_crop_width, target_width)
crop_height = max(base_crop_height, target_height)
self.log(f"最终裁剪尺寸: {crop_width}x{crop_height} (确保不小于目标尺寸)")
# 计算裁剪边界
crop_x1 = max(0, center_x - crop_width // 2)
crop_y1 = max(0, center_y - crop_height // 2)
crop_x2 = min(image.shape[1], center_x + crop_width // 2)
crop_y2 = min(image.shape[0], center_y + crop_height // 2)
# 如果裁剪区域超出图像边界,调整中心点
if crop_x1 == 0:
crop_x2 = min(image.shape[1], crop_width)
if crop_y1 == 0:
crop_y2 = min(image.shape[0], crop_height)
if crop_x2 == image.shape[1]:
crop_x1 = max(0, image.shape[1] - crop_width)
if crop_y2 == image.shape[0]:
crop_y1 = max(0, image.shape[0] - crop_height)
self.log(f"裁剪区域: ({crop_x1}, {crop_y1}) 到 ({crop_x2}, {crop_y2})")
self.log(f"实际裁剪尺寸: {crop_x2 - crop_x1}x{crop_y2 - crop_y1}")
# 从原始彩色图像中裁剪半身区域
half_body_image = image[crop_y1:crop_y2, crop_x1:crop_x2]
actual_crop_height, actual_crop_width = half_body_image.shape[:2]
crop_aspect_ratio = actual_crop_width / actual_crop_height
self.log(f"裁剪后尺寸: {actual_crop_width}x{actual_crop_height}, 宽高比: {crop_aspect_ratio:.2f}")
# 调整裁剪后的图像到目标尺寸
if actual_crop_width >= target_width and actual_crop_height >= target_height:
self.log("裁剪区域足够大,进行精确裁剪")
if crop_aspect_ratio > target_aspect_ratio:
# 太宽,调整高度
new_height = target_height
new_width = int(target_height * crop_aspect_ratio)
resized = cv2.resize(half_body_image, (new_width, new_height), interpolation=cv2.INTER_AREA)
crop_x = (new_width - target_width) // 2
resized_image = resized[0:target_height, crop_x:crop_x + target_width]
else:
# 太高,调整宽度
new_width = target_width
new_height = int(target_width / crop_aspect_ratio)
resized = cv2.resize(half_body_image, (new_width, new_height), interpolation=cv2.INTER_AREA)
crop_y = (new_height - target_height) // 2
resized_image = resized[crop_y:crop_y + target_height, 0:target_width]
else:
self.log("裁剪区域较小,使用智能调整方法")
# 使用新的智能调整方法,基于人脸大小决定处理方式
# 传递人脸位置信息给resize方法
# 计算在裁剪图像中的人脸位置
face_in_crop_x = x - crop_x1
face_in_crop_y = y - crop_y1
face_rect_in_crop = (face_in_crop_x, face_in_crop_y, w, h)
resized_image = self.resize_to_target(half_body_image, face_rect_in_crop, target_size)
output_ext = self.get_extension(output_format)
if self.limit_file_size.get() and output_format in ["JPG", "PNG"]:
max_size_kb = self.max_file_size_kb.get()
encoded_image, actual_size, quality, was_compressed = self.compress_to_target_size(
resized_image, max_size_kb, output_format, original_size_kb
)
if was_compressed:
self.log(f"文件已压缩,质量级别: {quality},实际大小: {actual_size:.1f}KB")
else:
self.log(f"文件未压缩,使用高质量保存,大小: {actual_size:.1f}KB")
else:
success, encoded_image = self.encode_image(resized_image, output_format, high_quality=True)
if encoded_image is not None:
output_dir = os.path.dirname(output_image_path)
output_name = os.path.splitext(os.path.basename(output_image_path))[0] + output_ext
final_output_path = os.path.join(output_dir, output_name)
with open(final_output_path, 'wb') as f:
f.write(encoded_image)
self.log(f"检测到人脸,半身图像已保存:{final_output_path}")
return "success"
else:
self.log(f"保存图像失败:{output_image_path}")
return "save_error"
except Exception as e:
self.log(f"处理图像 {input_image_path} 时出错: {e}")
self.log(f"详细错误信息: {traceback.format_exc()}")
return "process_error"
def get_extension(self, format_name):
format_map = {"JPG": ".jpg", "PNG": ".png", "BMP": ".bmp"}
return format_map.get(format_name, ".jpg")
def encode_image(self, image, format_name, high_quality=True):
if format_name == "JPG":
if high_quality:
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 95]
else:
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
return cv2.imencode('.jpg', image, encode_param)
elif format_name == "PNG":
if high_quality:
encode_param = [int(cv2.IMWRITE_PNG_COMPRESSION), 1]
else:
encode_param = [int(cv2.IMWRITE_PNG_COMPRESSION), 3]
return cv2.imencode('.png', image, encode_param)
elif format_name == "BMP":
return cv2.imencode('.bmp', image)
else:
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 95]
return cv2.imencode('.jpg', image, encode_param)
def process_images(self):
try:
input_folder = self.input_folder.get()
output_folder = self.output_folder.get()
target_size = (self.target_width.get(), self.target_height.get())
extend_ratio = self.extend_ratio.get()
output_format = self.output_format.get()
limit_size = self.limit_file_size.get()
max_size_kb = self.max_file_size_kb.get() if limit_size else 0
self.log(f"开始处理...")
self.log(f"输入文件夹: {input_folder}")
self.log(f"输出文件夹: {output_folder}")
self.log(f"目标尺寸: {target_size[0]}x{target_size[1]}")
self.log(f"扩展比例: {extend_ratio}")
self.log(f"输出格式: {output_format}")
if limit_size:
self.log(f"文件大小限制: {max_size_kb}KB")
self.flush_log_queue() # 立即显示重要信息
result = self.batch_detect_face_and_half_body(input_folder, output_folder, target_size, extend_ratio,
output_format)
self.log("处理完成!")
self.update_status("处理完成")
self.update_progress(self.total_files)
if self.cancel_processing:
messagebox.showinfo("取消", "处理已被用户取消")
else:
messagebox.showinfo("完成",
f"处理完成!\n成功: {result['success']}张\n需人工核查: {result['manual_check']}张\n处理失败: {result['errors']}张")
except Exception as e:
self.log(f"处理过程中出错: {str(e)}")
messagebox.showerror("错误", f"处理过程中出错: {str(e)}")
finally:
self.is_processing = False
self.process_btn.config(state="normal")
self.cancel_btn.config(state="disabled")
self.flush_log_queue() # 确保所有日志都显示
def batch_detect_face_and_half_body(self, input_folder, output_folder, target_size, extend_ratio, output_format):
if not os.path.exists(output_folder):
os.makedirs(output_folder)
self.log(f"创建输出文件夹: {output_folder}")
# 统计信息
success_count = 0
manual_check_count = 0
process_error_count = 0
read_error_count = 0
save_error_count = 0
success_no_face_detector_count = 0
cancelled_count = 0
# 获取图片文件
image_files = [f for f in os.listdir(input_folder) if
f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp'))]
self.total_files = len(image_files)
self.processed_files = 0
self.log(f"找到 {self.total_files} 个图像文件")
self.flush_log_queue() # 立即显示重要信息
self.progress['maximum'] = self.total_files
for i, filename in enumerate(image_files):
# 检查是否取消处理
if self.cancel_processing:
cancelled_count = self.total_files - i
self.log(f"取消处理,跳过剩余 {cancelled_count} 个文件")
break
self.update_status(f"处理中: {i + 1}/{self.total_files}")
input_image_path = os.path.join(input_folder, filename)
# 处理图片
name, _ = os.path.splitext(filename)
output_ext = self.get_extension(output_format)
output_filename = name + output_ext
output_image_path = os.path.join(output_folder, output_filename)
result = self.detect_face_and_half_body(input_image_path, output_image_path, target_size, extend_ratio,
output_format)
# 检查是否取消处理
if result == "cancelled":
cancelled_count = self.total_files - i
break
# 更新统计
if result == "success":
success_count += 1
elif result == "success_no_face_detector":
success_no_face_detector_count += 1
else:
name, _ = os.path.splitext(filename)
if result == "no_face":
manual_check_filename = f"{name}_需人工核查{output_ext}"
manual_check_path = os.path.join(output_folder, manual_check_filename)
manual_check_count += 1
mark = "需人工核查"
output_path = manual_check_path
elif result == "read_error":
read_error_filename = f"{name}_处理失败_读取错误{output_ext}"
read_error_path = os.path.join(output_folder, read_error_filename)
read_error_count += 1
mark = "处理失败_读取错误"
output_path = read_error_path
elif result == "save_error":
save_error_filename = f"{name}_处理失败_保存错误{output_ext}"
save_error_path = os.path.join(output_folder, save_error_filename)
save_error_count += 1
mark = "处理失败_保存错误"
output_path = save_error_path
else:
process_error_filename = f"{name}_处理失败{output_ext}"
process_error_path = os.path.join(output_folder, process_error_filename)
process_error_count += 1
mark = "处理失败"
output_path = process_error_path
self.log(f"处理失败: {filename}, 错误类型: {mark}, 将保存为: {os.path.basename(output_path)}")
try:
shutil.copy2(input_image_path, output_path)
self.log(f"已复制原图并标记为{mark}: {os.path.basename(output_path)}")
except Exception as e:
self.log(f"复制原图失败: {filename}, 错误: {e}")
try:
with open(input_image_path, 'rb') as f:
img_data = np.frombuffer(f.read(), dtype=np.uint8)
original_image = cv2.imdecode(img_data, cv2.IMREAD_COLOR)
if original_image is not None:
success, encoded_image = self.encode_image(original_image, output_format, high_quality=True)
if success:
with open(output_path, 'wb') as f:
f.write(encoded_image)
self.log(f"已使用OpenCV保存原图并标记为{mark}: {os.path.basename(output_path)}")
else:
self.log(f"使用OpenCV保存失败: {os.path.basename(output_path)}")
else:
self.log(f"无法读取原图: {filename}")
except Exception as e2:
self.log(f"所有保存尝试都失败: {filename}, 错误: {e2}")
# 更新进度
self.processed_files += 1
self.update_progress(self.processed_files)
# 定期清理内存
if i % 20 == 0 and i > 0:
gc.collect()
self.log(f"已处理 {i}/{self.total_files} 张图片,内存清理完成")
# 最终刷新日志
self.flush_log_queue()
# 输出统计结果
self.log("\n" + "=" * 50)
self.log("处理完成!统计结果:")
if self.cancel_processing:
self.log(f"用户取消了处理")
self.log(f"已处理: {self.processed_files} 张")
self.log(f"已取消: {cancelled_count} 张")
else:
self.log(f"成功处理(人脸检测): {success_count} 张")
self.log(f"成功处理(无检测器): {success_no_face_detector_count} 张")
self.log(f"需人工核查: {manual_check_count} 张")
self.log(f"读取错误: {read_error_count} 张")
self.log(f"保存错误: {save_error_count} 张")
self.log(f"其他处理错误: {process_error_count} 张")
total_processed = success_count + success_no_face_detector_count + manual_check_count + read_error_count + save_error_count + process_error_count
self.log(f"总计: {total_processed} 张")
self.log("=" * 50)
self.flush_log_queue()
return {
"success": success_count + success_no_face_detector_count,
"manual_check": manual_check_count,
"errors": read_error_count + save_error_count + process_error_count
}
def main():
root = tk.Tk()
app = PhotoProcessorApp(root)
root.mainloop()
if __name__ == "__main__":
main()