From b1a036936210bcec86b9a32f4602bd328b66d7e0 Mon Sep 17 00:00:00 2001 From: gqc Date: Fri, 17 Apr 2026 16:23:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 + .idea/PTset.iml | 8 + .idea/inspectionProfiles/Project_Default.xml | 21 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + ...st_universal.cpython-311.pyc.2160079884656 | Bin 0 -> 61013 bytes latest_universal.py | 872 ++++++++++++++++++ video_frame_extractor_gui.py | 239 +++++ 9 files changed, 1169 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/PTset.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 __pycache__/latest_universal.cpython-311.pyc.2160079884656 create mode 100644 latest_universal.py create mode 100644 video_frame_extractor_gui.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PTset.iml b/.idea/PTset.iml new file mode 100644 index 0000000..e814c35 --- /dev/null +++ b/.idea/PTset.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..d8cad39 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e57194d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..61250a4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/__pycache__/latest_universal.cpython-311.pyc.2160079884656 b/__pycache__/latest_universal.cpython-311.pyc.2160079884656 new file mode 100644 index 0000000000000000000000000000000000000000..6d57d299794cacd3506acdd499618f7ed3918abb GIT binary patch literal 61013 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", self.on_image_select) + + search_frame = tk.Frame(right_frame) + search_frame.pack(fill="x", pady=(0, 8)) + tk.Label(search_frame, text="搜索").pack(side="left") + self.search_entry = tk.Entry(search_frame) + self.search_entry.pack(side="left", fill="x", expand=True, padx=(6, 0)) + self.search_entry.bind("", self.search_image) + + tk.Label(right_frame, text="坐标 / 标注输入").pack(anchor="w") + self.coords_text = ScrolledText(right_frame, width=38, height=10) + self.coords_text.pack(fill="both", pady=(4, 8)) + + crop_frame = tk.Frame(right_frame) + crop_frame.pack(fill="x", pady=(0, 8)) + tk.Label(crop_frame, text="裁剪坐标").pack(side="left") + self.crop_entry = tk.Entry(crop_frame) + self.crop_entry.pack(side="left", fill="x", expand=True, padx=(6, 0)) + + tk.Label(right_frame, text="说明").pack(anchor="w") + self.help_text = tk.Label( + right_frame, + justify="left", + anchor="w", + text=( + "1. 多边形: 逐点点击,点回起点附近可闭合\n" + "2. 框选: 拖拽生成矩形,自动写入裁剪框\n" + "3. 输入支持:\n" + " rect:10,20,200,300\n" + " polygon:10,20,100,20,100,100\n" + " 纯 4 个数默认矩形,6 个及以上默认多边形\n" + "4. 批量回显支持 result 1: (x1 y1 x2 y2) 0.98, label" + ), + ) + self.help_text.pack(fill="x") + + def _bind_events(self): + self.root.bind("", self.undo_last_point) + + def set_status(self, message, color="darkgreen"): + self.status_label.config(text=f"状态: {message}", fg=color) + + def update_canvas_size(self, width, height): + self.canvas.config(scrollregion=(0, 0, width, height)) + self.canvas.config(width=min(width, 1100), height=min(height, 760)) + + def display_image(self): + self.canvas.delete("all") + self.clear_canvas_state() + if not self.image: + return + self.update_canvas_size(self.image.width, self.image.height) + self.photo = ImageTk.PhotoImage(self.image) + self.canvas.create_image(0, 0, image=self.photo, anchor="nw") + self.redraw_current_shape() + + def clear_canvas_state(self): + self.lines = [] + self.point_labels = [] + self.box_shape_id = None + self.box_text_id = None + self.box_preview = None + self.box_preview_text = None + self.clear_restored_shapes() + + def clear_restored_shapes(self): + for item in self.restored_points + self.restored_lines + self.restored_text_labels + self.restored_rectangles: + self.canvas.delete(item) + self.restored_points = [] + self.restored_lines = [] + self.restored_text_labels = [] + self.restored_rectangles = [] + + def update_input_boxes(self): + self.coords_text.delete("1.0", tk.END) + if self.current_shape: + self.coords_text.insert("1.0", self.serialize_annotation(self.current_shape)) + self.crop_entry.delete(0, tk.END) + if self.box_coords: + self.crop_entry.insert(0, f"{self.box_coords[0]},{self.box_coords[1]},{self.box_coords[2]},{self.box_coords[3]}") + + def redraw_current_shape(self): + if not self.current_shape: + self.update_input_boxes() + return + if self.current_shape["type"] == "polygon": + self.points = [tuple(point) for point in self.current_shape["points"]] + self.draw_polygon(self.points, color="red", editable=True) + elif self.current_shape["type"] == "rect": + self.box_coords = tuple(self.current_shape["coords"]) + self.draw_rect(self.box_coords, color="blue", editable=True) + self.update_input_boxes() + + def draw_polygon(self, points, color="red", editable=False): + if not points: + return + for index, (x, y) in enumerate(points, start=1): + oval = self.canvas.create_oval(x - 5, y - 5, x + 5, y + 5, fill=color, outline=color) + label = self.canvas.create_text( + x + 10, + y, + text=str(index), + fill="green" if editable else color, + font=("Arial", 14, "bold"), + ) + if editable: + self.point_labels.extend([oval, label]) + else: + self.restored_points.append(oval) + self.restored_text_labels.append(label) + for start, end in zip(points, points[1:]): + line = self.canvas.create_line(start, end, fill=color, width=2) + if editable: + self.lines.append(line) + else: + self.restored_lines.append(line) + if len(points) > 2: + line = self.canvas.create_line(points[-1], points[0], fill=color, width=2) + if editable: + self.lines.append(line) + else: + self.restored_lines.append(line) + + def draw_rect(self, coords, color="blue", editable=False, label=None): + x1, y1, x2, y2 = coords + rect = self.canvas.create_rectangle(x1, y1, x2, y2, outline=color, width=2) + width = abs(x2 - x1) + height = abs(y2 - y1) + text = self.canvas.create_text( + min(x1, x2) + 10, + min(y1, y2) + 12, + anchor="nw", + text=label or f"{width}x{height}", + fill=color, + font=("Arial", 12, "bold"), + ) + if editable: + self.box_shape_id = rect + self.box_text_id = text + else: + self.restored_rectangles.append(rect) + self.restored_text_labels.append(text) + + def enable_polygon_mode(self): + self.mode = "polygon" + self.canvas.unbind("") + self.canvas.unbind("") + self.canvas.unbind("") + self.canvas.bind("", self.on_click) + self.set_status("已切换到多边形模式") + + def enable_box_mode(self): + self.mode = "box" + self.canvas.unbind("") + self.canvas.bind("", self.on_box_press) + self.canvas.bind("", self.on_box_drag) + self.canvas.bind("", self.on_box_release) + self.set_status("已切换到框选模式") + + def on_click(self, event): + if self.mode != "polygon" or not self.image: + return + + x, y = self.clamp_to_image(event.x, event.y) + if self.box_coords: + self.box_coords = None + + if len(self.points) > 2: + first_x, first_y = self.points[0] + distance = ((x - first_x) ** 2 + (y - first_y) ** 2) ** 0.5 + if distance < 20: + self.current_shape = {"type": "polygon", "points": self.points[:]} + self.display_image() + self.set_status("多边形已闭合") + return + + self.points.append((x, y)) + self.current_shape = {"type": "polygon", "points": self.points[:]} + self.display_image() + + def on_box_press(self, event): + if self.mode != "box" or not self.image: + return + self.box_start = self.clamp_to_image(event.x, event.y) + self.box_coords = None + self.current_shape = None + self.display_image() + + def on_box_drag(self, event): + if self.mode != "box" or not self.image or not self.box_start: + return + x0, y0 = self.box_start + x1, y1 = self.clamp_to_image(event.x, event.y) + if self.box_preview: + self.canvas.delete(self.box_preview) + if self.box_preview_text: + self.canvas.delete(self.box_preview_text) + self.box_preview = self.canvas.create_rectangle(x0, y0, x1, y1, outline="blue", width=2, dash=(4, 2)) + self.box_preview_text = self.canvas.create_text( + min(x0, x1) + 10, + min(y0, y1) + 12, + anchor="nw", + text=f"{abs(x1 - x0)}x{abs(y1 - y0)}", + fill="blue", + font=("Arial", 12, "bold"), + ) + + def on_box_release(self, event): + if self.mode != "box" or not self.image or not self.box_start: + return + x0, y0 = self.box_start + x1, y1 = self.clamp_to_image(event.x, event.y) + self.box_start = None + self.box_coords = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + self.current_shape = {"type": "rect", "coords": self.box_coords} + self.display_image() + self.set_status("框选完成") + + def clamp_to_image(self, x, y): + if not self.image: + return x, y + x = min(max(int(self.canvas.canvasx(x)), 0), max(self.image.width - 1, 0)) + y = min(max(int(self.canvas.canvasy(y)), 0), max(self.image.height - 1, 0)) + return x, y + + def serialize_annotation(self, annotation): + if annotation["type"] == "rect": + x1, y1, x2, y2 = annotation["coords"] + return f"rect:{x1},{y1},{x2},{y2}" + points = annotation["points"] + flat = ",".join(f"{x},{y}" for x, y in points) + return f"polygon:{flat}" + + def parse_annotation(self, text): + raw = text.strip() + if not raw: + return None + lowered = raw.lower() + if lowered.startswith("rect:"): + coords = self.parse_ints(raw.split(":", 1)[1]) + if len(coords) != 4: + raise ValueError("矩形需要 4 个数字") + x1, y1, x2, y2 = coords + return {"type": "rect", "coords": (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))} + if lowered.startswith("polygon:"): + coords = self.parse_ints(raw.split(":", 1)[1]) + if len(coords) < 6 or len(coords) % 2 != 0: + raise ValueError("多边形至少需要 3 个点") + points = [(coords[i], coords[i + 1]) for i in range(0, len(coords), 2)] + return {"type": "polygon", "points": points} + + coords = self.parse_ints(raw) + if len(coords) == 4: + x1, y1, x2, y2 = coords + return {"type": "rect", "coords": (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))} + if len(coords) >= 6 and len(coords) % 2 == 0: + points = [(coords[i], coords[i + 1]) for i in range(0, len(coords), 2)] + return {"type": "polygon", "points": points} + raise ValueError("无法识别坐标格式") + + def parse_ints(self, text): + numbers = re.findall(r"-?\d+", text) + return [int(num) for num in numbers] + + def load_annotation(self, annotation, from_input=False): + self.current_shape = annotation + if annotation["type"] == "polygon": + self.points = [tuple(point) for point in annotation["points"]] + self.box_coords = None + else: + self.points = [] + self.box_coords = tuple(annotation["coords"]) + self.display_image() + if from_input: + self.set_status("已根据输入恢复标注") + + def save_coordinates(self): + if not self.current_shape: + self.set_status("请先绘制或恢复标注", "red") + return + + coords_str = self.serialize_annotation(self.current_shape) + self.update_input_boxes() + print(coords_str) + + if self.folder_path and self.image_list: + image_name = self.image_list[self.current_image_index] + self.coordinates[image_name] = coords_str + self.write_coordinates_file() + self.highlight_images_with_coordinates() + self.set_status(f"已保存 {image_name} 的标注") + if self.current_image_index < len(self.image_list) - 1: + self.next_image() + else: + self.set_status("已输出当前标注") + + def write_coordinates_file(self): + if not self.folder_path: + return + data_file = os.path.join(self.folder_path, "data.txt") + with open(data_file, "w", encoding="utf-8") as file: + for image_name, coords in self.coordinates.items(): + file.write(f"{image_name}: {coords}\n") + + def reset(self): + self.points = [] + self.current_shape = None + self.box_coords = None + self.box_start = None + self.display_image() + self.update_input_boxes() + self.set_status("已重置当前标注") + + def undo_last_point(self, event=None): + if self.current_shape and self.current_shape["type"] == "rect": + self.current_shape = None + self.box_coords = None + self.display_image() + self.update_input_boxes() + self.set_status("已清除矩形框") + return + + if self.points: + self.points.pop() + if self.points: + self.current_shape = {"type": "polygon", "points": self.points[:]} + else: + self.current_shape = None + self.display_image() + self.set_status("已撤回一个点") + + def upload_file(self): + file_types = [("Image/Video files", "*.jpg *.jpeg *.png *.bmp *.mp4 *.avi *.mov")] + file_path = filedialog.askopenfilename(filetypes=file_types) + if not file_path: + return + + self.file_path = file_path + if file_path.lower().endswith((".jpg", ".jpeg", ".png", ".bmp")): + self.image = Image.open(file_path).convert("RGB") + else: + cap = cv2.VideoCapture(file_path) + success, frame = cap.read() + cap.release() + if not success: + self.set_status("视频首帧读取失败", "red") + return + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + self.image = Image.fromarray(frame) + + self.current_shape = None + self.points = [] + self.box_coords = None + self.display_image() + self.set_status(f"已加载文件: {os.path.basename(file_path)}") + + def select_folder(self): + folder_path = filedialog.askdirectory() + if not folder_path: + return + self.folder_path = folder_path + self.refresh_image_list() + self.load_existing_coordinates() + if self.image_list: + self.current_image_index = 0 + self.load_image_from_folder() + self.set_status(f"已选择文件夹: {folder_path}") + + def refresh_image_list(self): + if not self.folder_path: + return + self.image_list = sorted( + [ + name + for name in os.listdir(self.folder_path) + if name.lower().endswith((".jpg", ".jpeg", ".png", ".bmp")) + ] + ) + self.image_listbox.delete(0, tk.END) + for image_name in self.image_list: + self.image_listbox.insert(tk.END, image_name) + self.highlight_images_with_coordinates() + + def load_existing_coordinates(self): + self.coordinates = {} + if not self.folder_path: + return + data_file = os.path.join(self.folder_path, "data.txt") + if not os.path.exists(data_file): + return + with open(data_file, "r", encoding="utf-8") as file: + for raw_line in file: + line = raw_line.strip() + if not line or ": " not in line: + continue + image_name, coords_str = line.split(": ", 1) + self.coordinates[image_name] = coords_str + + def load_image_from_folder(self): + if not self.folder_path or not self.image_list: + return + image_name = self.image_list[self.current_image_index] + image_path = os.path.join(self.folder_path, image_name) + self.image = Image.open(image_path).convert("RGB") + self.current_shape = None + self.points = [] + self.box_coords = None + self.display_image() + self.highlight_current_image() + self.restore_coordinates_from_file() + self.set_status(f"已加载: {image_name}") + + def on_image_select(self, event): + if not self.image_listbox.curselection(): + return + self.current_image_index = self.image_listbox.curselection()[0] + self.load_image_from_folder() + + def highlight_current_image(self): + for index, image_name in enumerate(self.image_list): + color = "blue" if image_name in self.coordinates else "black" + self.image_listbox.itemconfig(index, {"fg": color}) + if self.image_list: + self.image_listbox.itemconfig(self.current_image_index, {"fg": "red"}) + self.image_listbox.selection_clear(0, tk.END) + self.image_listbox.selection_set(self.current_image_index) + self.image_listbox.see(self.current_image_index) + + def highlight_images_with_coordinates(self): + for index, image_name in enumerate(self.image_list): + color = "blue" if image_name in self.coordinates else "black" + self.image_listbox.itemconfig(index, {"fg": color}) + + def prev_image(self): + if not self.image_list: + return + self.current_image_index = max(0, self.current_image_index - 1) + self.load_image_from_folder() + + def next_image(self): + if not self.image_list: + return + self.current_image_index = min(len(self.image_list) - 1, self.current_image_index + 1) + self.load_image_from_folder() + + def search_image(self, event=None): + keyword = self.search_entry.get().strip().lower() + if not keyword: + return + for index, image_name in enumerate(self.image_list): + if keyword in image_name.lower(): + self.current_image_index = index + self.load_image_from_folder() + return + self.set_status("未找到匹配图片", "red") + + def restore_coordinates(self): + if not self.image: + self.set_status("请先加载图片", "red") + return + coords_str = self.coords_text.get("1.0", tk.END).strip() + if not coords_str: + self.set_status("请输入坐标内容", "red") + return + try: + annotation = self.parse_annotation(coords_str) + except ValueError as exc: + self.set_status(str(exc), "red") + return + self.load_annotation(annotation, from_input=True) + + def restore_coordinates_from_file(self): + if not self.image_list: + return + image_name = self.image_list[self.current_image_index] + coords_str = self.coordinates.get(image_name) + if not coords_str: + self.coords_text.delete("1.0", tk.END) + self.crop_entry.delete(0, tk.END) + return + try: + annotation = self.parse_annotation(coords_str) + except ValueError: + self.coords_text.delete("1.0", tk.END) + self.coords_text.insert("1.0", coords_str) + self.set_status(f"{image_name} 的坐标格式无法自动恢复", "red") + return + self.load_annotation(annotation) + + def batch_restore_coordinates(self): + if not self.image: + self.set_status("请先加载图片", "red") + return + input_text = self.coords_text.get("1.0", tk.END).strip() + if not input_text: + self.set_status("请输入批量框内容", "red") + return + + self.clear_restored_shapes() + count = 0 + pattern = re.compile( + r"result\s+(\d+):\s*\(\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\)\s*[\d.]+\s*,\s*([A-Za-z0-9_\-]+)" + ) + for line in input_text.splitlines(): + match = pattern.search(line) + if not match: + continue + result_id, x1, y1, x2, y2, label = match.groups() + coords = (int(x1), int(y1), int(x2), int(y2)) + self.draw_rect(coords, color="yellow", editable=False, label=f"result {result_id} {label}") + count += 1 + + if count == 0: + self.set_status("没有识别到批量框格式", "red") + else: + self.set_status(f"已回显 {count} 个检测框") + + def get_crop_box(self): + if self.box_coords: + return self.box_coords + raw = self.crop_entry.get().strip() + if not raw: + raise ValueError("请先框选或输入裁剪坐标") + annotation = self.parse_annotation(raw) + if annotation["type"] == "rect": + return annotation["coords"] + xs = [point[0] for point in annotation["points"]] + ys = [point[1] for point in annotation["points"]] + return min(xs), min(ys), max(xs), max(ys) + + def crop_image(self): + if not self.image: + self.set_status("请先加载图片", "red") + return + try: + x1, y1, x2, y2 = self.get_crop_box() + except ValueError as exc: + self.set_status(str(exc), "red") + return + + x1 = max(0, min(x1, self.image.width)) + y1 = max(0, min(y1, self.image.height)) + x2 = max(0, min(x2, self.image.width)) + y2 = max(0, min(y2, self.image.height)) + if x2 <= x1 or y2 <= y1: + self.set_status("裁剪区域无效", "red") + return + + cropped = self.image.crop((x1, y1, x2, y2)) + target_size = 640 + ratio = min(target_size / cropped.width, target_size / cropped.height) + new_width = max(1, int(cropped.width * ratio)) + new_height = max(1, int(cropped.height * ratio)) + resized = cropped.resize((new_width, new_height), Image.Resampling.LANCZOS) + background = Image.new("RGB", (target_size, target_size), (0, 0, 0)) + offset = ((target_size - new_width) // 2, (target_size - new_height) // 2) + background.paste(resized, offset) + + save_path = filedialog.asksaveasfilename( + title="保存裁剪结果", + defaultextension=".jpg", + filetypes=[("JPEG files", "*.jpg"), ("PNG files", "*.png")], + ) + if save_path: + background.save(save_path) + + self.image = background + self.current_shape = None + self.points = [] + self.box_coords = None + self.display_image() + self.set_status(f"裁剪完成: ({x1},{y1})-({x2},{y2})") + + def compress_image(self): + if not self.image: + self.set_status("请先加载图片", "red") + return + self.image = self.make_640_image(self.image) + self.current_shape = None + self.points = [] + self.box_coords = None + self.display_image() + self.set_status("当前图片已压缩到 640x640") + + def compress_all_images(self): + if not self.folder_path or not self.image_list: + self.set_status("请先选择工作文件夹", "red") + return + for image_name in self.image_list: + image_path = os.path.join(self.folder_path, image_name) + image = Image.open(image_path).convert("RGB") + compressed = self.make_640_image(image) + compressed.save(image_path) + self.load_image_from_folder() + self.set_status("批量压缩完成") + + def make_640_image(self, image): + target_size = 640 + ratio = min(target_size / image.width, target_size / image.height) + new_width = max(1, int(image.width * ratio)) + new_height = max(1, int(image.height * ratio)) + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + background = Image.new("RGB", (target_size, target_size), (0, 0, 0)) + offset = ((target_size - new_width) // 2, (target_size - new_height) // 2) + background.paste(resized, offset) + return background + + def connect_rtsp(self): + rtsp_url = simpledialog.askstring("RTSP 连接", "请输入 RTSP 地址:") + if not rtsp_url: + return + self.rtsp_url = rtsp_url + self.set_status("正在连接 RTSP...", "blue") + self.rtsp_thread = threading.Thread(target=self._connect_rtsp_thread, daemon=True) + self.rtsp_thread.start() + + def _connect_rtsp_thread(self): + try: + cap = cv2.VideoCapture(self.rtsp_url) + cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000) + if not cap.isOpened(): + self.root.after(0, lambda: self.set_status("RTSP 连接失败", "red")) + return + success, _ = cap.read() + if not success: + cap.release() + self.root.after(0, lambda: self.set_status("RTSP 读取失败", "red")) + return + self.cap = cap + self.root.after(0, lambda: self.set_status("RTSP 已连接")) + except Exception as exc: + self.root.after(0, lambda: self.set_status(f"RTSP 错误: {exc}", "red")) + + def capture_rtsp_frame(self): + if not self.cap or not self.cap.isOpened(): + self.set_status("RTSP 尚未连接", "red") + return + success, frame = self.cap.read() + if not success: + self.set_status("RTSP 截图失败", "red") + return + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + self.image = Image.fromarray(frame_rgb) + self.current_shape = None + self.points = [] + self.box_coords = None + self.display_image() + + save_path = filedialog.asksaveasfilename( + title="保存 RTSP 截图", + initialfile=f"rtsp_capture_{time.strftime('%Y%m%d_%H%M%S')}.jpg", + defaultextension=".jpg", + filetypes=[("JPEG files", "*.jpg"), ("PNG files", "*.png")], + ) + if save_path: + cv2.imwrite(save_path, frame) + if self.folder_path: + copied_path = os.path.join(self.folder_path, os.path.basename(save_path)) + shutil.copy2(save_path, copied_path) + self.refresh_image_list() + self.set_status("RTSP 截图完成") + + def disconnect_rtsp(self): + if self.cap: + self.cap.release() + self.cap = None + self.set_status("RTSP 已断开") + + def extract_video_frames(self): + video_path = filedialog.askopenfilename( + title="选择视频文件", + filetypes=[("Video files", "*.mp4 *.avi *.mov *.mkv")], + ) + if not video_path: + return + output_folder = filedialog.askdirectory(title="选择输出文件夹") + if not output_folder: + return + target_fps = simpledialog.askinteger("抽帧", "请输入目标帧率(FPS):", initialvalue=5, minvalue=1, maxvalue=60) + if not target_fps: + return + + video = cv2.VideoCapture(video_path) + original_fps = video.get(cv2.CAP_PROP_FPS) + if not original_fps: + video.release() + self.set_status("无法读取视频帧率", "red") + return + + interval = max(1, int(original_fps / target_fps)) + frame_index = 0 + saved_count = 0 + while True: + success, frame = video.read() + if not success: + break + if frame_index % interval == 0: + save_path = os.path.join(output_folder, f"frame_{saved_count:05d}.jpg") + cv2.imwrite(save_path, frame) + saved_count += 1 + frame_index += 1 + video.release() + self.set_status(f"抽帧完成,保存 {saved_count} 张") + + def collect_labeled_images(self): + label_folder = filedialog.askdirectory(title="选择标签文件夹") + if not label_folder: + return + image_source_folder = filedialog.askdirectory(title="选择图片源文件夹") + if not image_source_folder: + return + output_folder = filedialog.askdirectory(title="选择输出文件夹") + if not output_folder: + return + + images_out = os.path.join(output_folder, "images") + labels_out = os.path.join(output_folder, "labels") + os.makedirs(images_out, exist_ok=True) + os.makedirs(labels_out, exist_ok=True) + + count = 0 + for filename in os.listdir(label_folder): + if not filename.endswith(".txt") or filename == "classes.txt": + continue + base_name = os.path.splitext(filename)[0] + src_label = os.path.join(label_folder, filename) + found_image = None + for suffix in (".jpg", ".jpeg", ".png", ".bmp"): + candidate = os.path.join(image_source_folder, base_name + suffix) + if os.path.exists(candidate): + found_image = candidate + break + if not found_image: + continue + shutil.copy2(found_image, os.path.join(images_out, os.path.basename(found_image))) + shutil.copy2(src_label, os.path.join(labels_out, filename)) + count += 1 + + classes_path = os.path.join(label_folder, "classes.txt") + if os.path.exists(classes_path): + shutil.copy2(classes_path, os.path.join(output_folder, "classes.txt")) + + self.set_status(f"整理完成,共复制 {count} 组图像和标签") + messagebox.showinfo("完成", f"已整理 {count} 组标注数据") + + +def main(): + root = tk.Tk() + UniversalAnnotationTool(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/video_frame_extractor_gui.py b/video_frame_extractor_gui.py new file mode 100644 index 0000000..695c615 --- /dev/null +++ b/video_frame_extractor_gui.py @@ -0,0 +1,239 @@ +import math +import os +import tkinter as tk +from tkinter import filedialog, messagebox + +import cv2 + + +class VideoFrameExtractorApp: + def __init__(self, root): + self.root = root + self.root.title("视频拆分图片工具") + self.root.geometry("760x520") + + self.video_path = "" + self.output_dir = "" + self.video_info = {} + + self._build_ui() + + def _build_ui(self): + main = tk.Frame(self.root, padx=14, pady=14) + main.pack(fill="both", expand=True) + + source_frame = tk.LabelFrame(main, text="视频源", padx=10, pady=10) + source_frame.pack(fill="x", pady=(0, 10)) + + self.video_path_var = tk.StringVar() + tk.Entry(source_frame, textvariable=self.video_path_var).pack(side="left", fill="x", expand=True) + tk.Button(source_frame, text="添加视频", width=12, command=self.choose_video).pack(side="left", padx=(8, 0)) + + info_frame = tk.LabelFrame(main, text="视频信息", padx=10, pady=10) + info_frame.pack(fill="x", pady=(0, 10)) + + self.info_labels = {} + fields = [ + ("file_name", "文件名"), + ("resolution", "分辨率"), + ("fps", "视频帧率"), + ("frame_count", "总帧数"), + ("duration", "总时长"), + ("codec", "编码格式"), + ("size", "文件大小"), + ] + for row, (key, title) in enumerate(fields): + tk.Label(info_frame, text=f"{title}:", width=12, anchor="w").grid(row=row, column=0, sticky="w", pady=2) + label = tk.Label(info_frame, text="未加载", anchor="w") + label.grid(row=row, column=1, sticky="w", pady=2) + self.info_labels[key] = label + + settings_frame = tk.LabelFrame(main, text="抽帧设置", padx=10, pady=10) + settings_frame.pack(fill="x", pady=(0, 10)) + + tk.Label(settings_frame, text="每秒抽取图片数:", width=14, anchor="w").grid(row=0, column=0, sticky="w") + self.target_fps_var = tk.StringVar(value="5") + tk.Entry(settings_frame, textvariable=self.target_fps_var, width=12).grid(row=0, column=1, sticky="w") + + tk.Label(settings_frame, text="输出文件夹:", width=14, anchor="w").grid(row=1, column=0, sticky="w", pady=(10, 0)) + self.output_dir_var = tk.StringVar() + tk.Entry(settings_frame, textvariable=self.output_dir_var).grid(row=1, column=1, sticky="ew", pady=(10, 0)) + tk.Button(settings_frame, text="选择输出", width=12, command=self.choose_output_dir).grid(row=1, column=2, padx=(8, 0), pady=(10, 0)) + settings_frame.grid_columnconfigure(1, weight=1) + + action_frame = tk.Frame(main) + action_frame.pack(fill="x", pady=(0, 10)) + tk.Button(action_frame, text="开始拆分", width=14, command=self.extract_frames).pack(side="left") + + self.progress_var = tk.StringVar(value="等待开始") + tk.Label(main, textvariable=self.progress_var, anchor="w", justify="left", fg="darkgreen").pack(fill="x") + + def choose_video(self): + video_path = filedialog.askopenfilename( + title="选择视频文件", + filetypes=[("Video files", "*.mp4 *.avi *.mov *.mkv *.flv *.wmv"), ("All files", "*.*")], + ) + if not video_path: + return + + self.video_path = video_path + self.video_path_var.set(video_path) + self.load_video_info(video_path) + + if not self.output_dir_var.get().strip(): + default_name = os.path.splitext(os.path.basename(video_path))[0] + "_frames" + self.output_dir = os.path.join(os.path.dirname(video_path), default_name) + self.output_dir_var.set(self.output_dir) + + def choose_output_dir(self): + output_dir = filedialog.askdirectory(title="选择输出文件夹") + if not output_dir: + return + self.output_dir = output_dir + self.output_dir_var.set(output_dir) + + def load_video_info(self, video_path): + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + messagebox.showerror("错误", "无法打开该视频文件。") + return + + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + duration_seconds = frame_count / fps if fps > 0 else 0 + fourcc_value = int(cap.get(cv2.CAP_PROP_FOURCC) or 0) + cap.release() + + self.video_info = { + "file_name": os.path.basename(video_path), + "resolution": f"{width} x {height}", + "fps": f"{fps:.3f}" if fps else "未知", + "frame_count": str(frame_count) if frame_count else "未知", + "duration": self.format_duration(duration_seconds), + "codec": self.decode_fourcc(fourcc_value), + "size": self.format_size(os.path.getsize(video_path)), + } + + for key, value in self.video_info.items(): + self.info_labels[key].config(text=value) + + self.progress_var.set("视频信息已读取,可以设置抽帧参数。") + + def decode_fourcc(self, value): + if value <= 0: + return "未知" + chars = [chr((value >> 8 * i) & 0xFF) for i in range(4)] + codec = "".join(chars).strip() + return codec if codec else "未知" + + def format_duration(self, seconds): + seconds = max(0, int(round(seconds))) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + def format_size(self, size_bytes): + units = ["B", "KB", "MB", "GB", "TB"] + size = float(size_bytes) + index = 0 + while size >= 1024 and index < len(units) - 1: + size /= 1024 + index += 1 + return f"{size:.2f} {units[index]}" + + def extract_frames(self): + video_path = self.video_path_var.get().strip() + output_dir = self.output_dir_var.get().strip() + target_fps_text = self.target_fps_var.get().strip() + + if not video_path: + messagebox.showwarning("提示", "请先添加视频文件。") + return + if not os.path.exists(video_path): + messagebox.showwarning("提示", "视频文件不存在,请重新选择。") + return + if not output_dir: + messagebox.showwarning("提示", "请选择输出文件夹。") + return + + try: + target_fps = float(target_fps_text) + except ValueError: + messagebox.showwarning("提示", "每秒抽取图片数必须是数字。") + return + + if target_fps <= 0: + messagebox.showwarning("提示", "每秒抽取图片数必须大于 0。") + return + + os.makedirs(output_dir, exist_ok=True) + + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + messagebox.showerror("错误", "无法打开视频文件。") + return + + original_fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + if original_fps <= 0: + cap.release() + messagebox.showerror("错误", "无法读取视频帧率。") + return + + if target_fps >= original_fps: + interval = 1 + actual_fps = original_fps + else: + interval = max(1, int(round(original_fps / target_fps))) + actual_fps = original_fps / interval + + frame_index = 0 + saved_count = 0 + base_name = os.path.splitext(os.path.basename(video_path))[0] + + self.progress_var.set( + f"开始拆分:原始 FPS={original_fps:.3f},目标={target_fps:.3f},实际约={actual_fps:.3f} 张/秒" + ) + self.root.update_idletasks() + + while True: + success, frame = cap.read() + if not success: + break + + if frame_index % interval == 0: + timestamp = frame_index / original_fps + output_name = f"{base_name}_{saved_count:06d}_{timestamp:09.3f}s.jpg" + output_path = os.path.join(output_dir, output_name) + cv2.imwrite(output_path, frame) + saved_count += 1 + + frame_index += 1 + + if frame_count and frame_index % 100 == 0: + percent = frame_index / frame_count * 100 + self.progress_var.set(f"处理中:{frame_index}/{frame_count} 帧,约 {percent:.1f}%") + self.root.update_idletasks() + + cap.release() + + self.progress_var.set( + f"拆分完成:共导出 {saved_count} 张图片,输出目录:{output_dir}" + ) + messagebox.showinfo( + "完成", + f"视频拆分完成。\n\n导出图片:{saved_count} 张\n输出目录:{output_dir}\n实际抽取频率:约 {actual_fps:.3f} 张/秒", + ) + + +def main(): + root = tk.Tk() + VideoFrameExtractorApp(root) + root.mainloop() + + +if __name__ == "__main__": + main()