590 lines
22 KiB
Python
590 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
图片标注工具
|
||
用于手动标注图像中的目标区域并保存位置坐标(百分比格式)到txt文件
|
||
支持矩形框标注、预览、编辑和删除功能
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import tkinter as tk
|
||
from tkinter import ttk, filedialog, messagebox, simpledialog
|
||
from PIL import Image, ImageTk, ImageDraw
|
||
import cv2
|
||
import numpy as np
|
||
from datetime import datetime
|
||
from typing import List, Optional, Tuple
|
||
import threading
|
||
|
||
|
||
class ImageAnnotationTool:
|
||
"""图片标注工具 - 支持手动矩形框标注"""
|
||
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title("图片标注工具")
|
||
self.root.geometry("1200x800")
|
||
|
||
# 数据存储
|
||
self.current_image_path = None
|
||
self.current_image = None
|
||
self.image_pil = None
|
||
self.image_tk = None
|
||
self.zoom_factor = 1.0
|
||
self.image_display = None
|
||
self.annotations = [] # 存储标注:[(x1, y1, x2, y2), ...] # 移除了label字段
|
||
|
||
# 标注状态
|
||
self.drawing = False
|
||
self.start_x = None
|
||
self.start_y = None
|
||
self.current_rect = None
|
||
self.selected_annotation = -1 # 当前选中的标注索引
|
||
self.drag_mode = False # 是否在拖拽模式
|
||
self.drag_start = None # 拖拽起始点
|
||
|
||
# 缩放和平移
|
||
self.offset_x = 0
|
||
self.offset_y = 0
|
||
self.pan_start_x = 0
|
||
self.pan_start_y = 0
|
||
self.panning = False
|
||
|
||
# 创建UI
|
||
self.setup_ui()
|
||
|
||
# 绑定事件
|
||
self.setup_bindings()
|
||
|
||
# 更新状态
|
||
self.update_status()
|
||
|
||
def setup_ui(self):
|
||
"""设置用户界面"""
|
||
# 创建主框架
|
||
main_frame = ttk.Frame(self.root)
|
||
main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||
|
||
# 左侧控制面板
|
||
control_frame = ttk.Frame(main_frame, width=250)
|
||
control_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 5))
|
||
control_frame.pack_propagate(False)
|
||
|
||
# 文件操作
|
||
file_group = ttk.LabelFrame(control_frame, text="文件操作", padding=5)
|
||
file_group.pack(fill=tk.X, pady=(0, 10))
|
||
|
||
ttk.Button(file_group, text="打开图片", command=self.open_image).pack(fill=tk.X, pady=2)
|
||
ttk.Button(file_group, text="保存标注", command=self.save_annotations).pack(fill=tk.X, pady=2)
|
||
ttk.Button(file_group, text="加载标注", command=self.load_annotations).pack(fill=tk.X, pady=2)
|
||
|
||
# 标注操作
|
||
anno_group = ttk.LabelFrame(control_frame, text="标注操作", padding=5)
|
||
anno_group.pack(fill=tk.X, pady=(0, 10))
|
||
|
||
ttk.Button(anno_group, text="清除当前标注", command=self.clear_current_annotation).pack(fill=tk.X, pady=2)
|
||
ttk.Button(anno_group, text="删除所有标注", command=self.clear_all_annotations).pack(fill=tk.X, pady=2)
|
||
ttk.Button(anno_group, text="删除选中标注", command=self.delete_selected_annotation).pack(fill=tk.X, pady=2)
|
||
|
||
# 标注列表
|
||
list_group = ttk.LabelFrame(control_frame, text="标注列表", padding=5)
|
||
list_group.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||
|
||
# 创建列表框和滚动条
|
||
list_frame = ttk.Frame(list_group)
|
||
list_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
||
self.annotation_listbox = tk.Listbox(list_frame, height=15)
|
||
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.annotation_listbox.yview)
|
||
self.annotation_listbox.configure(yscrollcommand=scrollbar.set)
|
||
|
||
self.annotation_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||
|
||
self.annotation_listbox.bind('<<ListboxSelect>>', self.on_annotation_select)
|
||
|
||
# 信息显示
|
||
info_group = ttk.LabelFrame(control_frame, text="信息", padding=5)
|
||
info_group.pack(fill=tk.X)
|
||
|
||
self.info_label = ttk.Label(info_group, text="未加载图片", wraplength=220)
|
||
self.info_label.pack(fill=tk.X, pady=2)
|
||
|
||
self.status_label = ttk.Label(info_group, text="状态: 就绪", wraplength=220)
|
||
self.status_label.pack(fill=tk.X, pady=2)
|
||
|
||
# 右侧画布区域
|
||
canvas_frame = ttk.Frame(main_frame)
|
||
canvas_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
|
||
|
||
# 工具栏
|
||
toolbar = ttk.Frame(canvas_frame)
|
||
toolbar.pack(fill=tk.X, pady=(0, 5))
|
||
|
||
ttk.Button(toolbar, text="放大", command=self.zoom_in).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="缩小", command=self.zoom_out).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="适应窗口", command=self.fit_to_window).pack(side=tk.LEFT, padx=2)
|
||
ttk.Button(toolbar, text="重置视图", command=self.reset_view).pack(side=tk.LEFT, padx=2)
|
||
|
||
self.draw_mode_var = tk.BooleanVar(value=True)
|
||
ttk.Checkbutton(toolbar, text="绘制模式", variable=self.draw_mode_var).pack(side=tk.LEFT, padx=5)
|
||
|
||
# 画布
|
||
self.canvas = tk.Canvas(canvas_frame, bg='gray', cursor='cross')
|
||
self.canvas.pack(fill=tk.BOTH, expand=True)
|
||
|
||
def setup_bindings(self):
|
||
"""设置事件绑定"""
|
||
self.canvas.bind('<ButtonPress-1>', self.on_mouse_down)
|
||
self.canvas.bind('<B1-Motion>', self.on_mouse_move)
|
||
self.canvas.bind('<ButtonRelease-1>', self.on_mouse_up)
|
||
self.canvas.bind('<Control-ButtonPress-1>', self.on_pan_start)
|
||
self.canvas.bind('<Control-B1-Motion>', self.on_pan_move)
|
||
self.canvas.bind('<Control-ButtonRelease-1>', self.on_pan_end)
|
||
self.root.bind('<Delete>', lambda e: self.delete_selected_annotation())
|
||
self.root.bind('<Control-s>', lambda e: self.save_annotations())
|
||
self.root.bind('<Control-o>', lambda e: self.open_image())
|
||
|
||
def open_image(self):
|
||
"""打开图片文件"""
|
||
file_path = filedialog.askopenfilename(
|
||
title="选择图片文件",
|
||
filetypes=[
|
||
("图片文件", "*.jpg *.jpeg *.png *.bmp *.tiff"),
|
||
("所有文件", "*.*")
|
||
]
|
||
)
|
||
|
||
if not file_path:
|
||
return
|
||
|
||
try:
|
||
# 加载图片
|
||
self.current_image_path = file_path
|
||
self.image_pil = Image.open(file_path)
|
||
|
||
# 重置状态
|
||
self.annotations = []
|
||
self.selected_annotation = -1
|
||
self.zoom_factor = 1.0
|
||
self.offset_x = 0
|
||
self.offset_y = 0
|
||
|
||
# 适应窗口
|
||
self.fit_to_window()
|
||
|
||
# 更新标注列表
|
||
self.update_annotation_list()
|
||
|
||
# 更新信息
|
||
self.update_info()
|
||
self.update_status("图片已加载")
|
||
|
||
except Exception as e:
|
||
messagebox.showerror("错误", f"无法加载图片: {e}")
|
||
|
||
def fit_to_window(self):
|
||
"""调整图片适应窗口"""
|
||
if not self.image_pil:
|
||
return
|
||
|
||
canvas_width = self.canvas.winfo_width()
|
||
canvas_height = self.canvas.winfo_height()
|
||
|
||
if canvas_width <= 1 or canvas_height <= 1:
|
||
canvas_width = 800
|
||
canvas_height = 600
|
||
|
||
img_width, img_height = self.image_pil.size
|
||
|
||
# 计算缩放比例
|
||
scale_x = canvas_width / img_width
|
||
scale_y = canvas_height / img_height
|
||
self.zoom_factor = min(scale_x, scale_y)
|
||
|
||
# 计算偏移量使图片居中
|
||
scaled_width = img_width * self.zoom_factor
|
||
scaled_height = img_height * self.zoom_factor
|
||
self.offset_x = (canvas_width - scaled_width) / 2
|
||
self.offset_y = (canvas_height - scaled_height) / 2
|
||
|
||
self.display_image()
|
||
|
||
def reset_view(self):
|
||
"""重置视图"""
|
||
self.zoom_factor = 1.0
|
||
self.offset_x = 0
|
||
self.offset_y = 0
|
||
self.fit_to_window()
|
||
|
||
def zoom_in(self):
|
||
"""放大"""
|
||
self.zoom_factor *= 1.2
|
||
self.display_image()
|
||
|
||
def zoom_out(self):
|
||
"""缩小"""
|
||
self.zoom_factor /= 1.2
|
||
self.display_image()
|
||
|
||
def display_image(self):
|
||
"""显示图片"""
|
||
if not self.image_pil:
|
||
return
|
||
|
||
# 缩放图片
|
||
img_width, img_height = self.image_pil.size
|
||
new_width = int(img_width * self.zoom_factor)
|
||
new_height = int(img_height * self.zoom_factor)
|
||
|
||
scaled_image = self.image_pil.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
# 转换为PhotoImage
|
||
self.image_tk = ImageTk.PhotoImage(scaled_image)
|
||
|
||
# 清除画布
|
||
self.canvas.delete("all")
|
||
|
||
# 显示图片
|
||
self.canvas.create_image(self.offset_x, self.offset_y, anchor=tk.NW, image=self.image_tk)
|
||
|
||
# 绘制标注
|
||
self.draw_annotations()
|
||
|
||
def draw_annotations(self):
|
||
"""绘制所有标注"""
|
||
if not self.image_pil:
|
||
return
|
||
|
||
img_width, img_height = self.image_pil.size
|
||
|
||
for idx, (x1, y1, x2, y2) in enumerate(self.annotations):
|
||
# 转换坐标
|
||
canvas_x1 = x1 * self.zoom_factor + self.offset_x
|
||
canvas_y1 = y1 * self.zoom_factor + self.offset_y
|
||
canvas_x2 = x2 * self.zoom_factor + self.offset_x
|
||
canvas_y2 = y2 * self.zoom_factor + self.offset_y
|
||
|
||
# 确定颜色
|
||
if idx == self.selected_annotation:
|
||
color = "red"
|
||
width = 3
|
||
else:
|
||
color = "blue"
|
||
width = 2
|
||
|
||
# 绘制矩形
|
||
self.canvas.create_rectangle(canvas_x1, canvas_y1, canvas_x2, canvas_y2,
|
||
outline=color, width=width, tags=f"rect_{idx}")
|
||
|
||
# 绘制序号
|
||
self.canvas.create_text(canvas_x1 + 5, canvas_y1 + 15,
|
||
text=str(idx + 1), anchor=tk.NW,
|
||
fill=color, font=("Arial", 10, "bold"),
|
||
tags=f"label_{idx}")
|
||
|
||
def canvas_to_image_coords(self, canvas_x, canvas_y):
|
||
"""将画布坐标转换为图片坐标"""
|
||
img_x = (canvas_x - self.offset_x) / self.zoom_factor
|
||
img_y = (canvas_y - self.offset_y) / self.zoom_factor
|
||
return img_x, img_y
|
||
|
||
def on_mouse_down(self, event):
|
||
"""鼠标按下事件"""
|
||
if self.draw_mode_var.get():
|
||
# 绘制模式
|
||
img_x, img_y = self.canvas_to_image_coords(event.x, event.y)
|
||
if 0 <= img_x <= self.image_pil.size[0] and 0 <= img_y <= self.image_pil.size[1]:
|
||
self.drawing = True
|
||
self.start_x = img_x
|
||
self.start_y = img_y
|
||
else:
|
||
# 选择模式 - 查找最近的标注
|
||
self.find_nearest_annotation(event.x, event.y)
|
||
|
||
def find_nearest_annotation(self, canvas_x, canvas_y):
|
||
"""查找最近的标注"""
|
||
if not self.annotations:
|
||
return
|
||
|
||
min_dist = float('inf')
|
||
nearest_idx = -1
|
||
|
||
for idx, (x1, y1, x2, y2) in enumerate(self.annotations):
|
||
# 转换坐标
|
||
cx1 = x1 * self.zoom_factor + self.offset_x
|
||
cy1 = y1 * self.zoom_factor + self.offset_y
|
||
cx2 = x2 * self.zoom_factor + self.offset_x
|
||
cy2 = y2 * self.zoom_factor + self.offset_y
|
||
|
||
# 计算到矩形边界的距离
|
||
if cx1 <= canvas_x <= cx2 and cy1 <= canvas_y <= cy2:
|
||
# 点在矩形内
|
||
dist = 0
|
||
else:
|
||
# 计算到最近边的距离
|
||
dx = max(cx1 - canvas_x, 0, canvas_x - cx2)
|
||
dy = max(cy1 - canvas_y, 0, canvas_y - cy2)
|
||
dist = (dx**2 + dy**2)**0.5
|
||
|
||
if dist < min_dist:
|
||
min_dist = dist
|
||
nearest_idx = idx
|
||
|
||
# 如果距离足够近,选中该标注
|
||
if min_dist < 20: # 20像素阈值
|
||
self.selected_annotation = nearest_idx
|
||
self.annotation_listbox.selection_clear(0, tk.END)
|
||
self.annotation_listbox.selection_set(nearest_idx)
|
||
self.annotation_listbox.see(nearest_idx)
|
||
self.display_image()
|
||
self.update_status(f"选中标注 #{nearest_idx + 1}")
|
||
|
||
def on_mouse_move(self, event):
|
||
"""鼠标移动事件"""
|
||
if self.drawing:
|
||
# 绘制临时矩形
|
||
if self.current_rect:
|
||
self.canvas.delete(self.current_rect)
|
||
|
||
img_x, img_y = self.canvas_to_image_coords(event.x, event.y)
|
||
canvas_x1 = self.start_x * self.zoom_factor + self.offset_x
|
||
canvas_y1 = self.start_y * self.zoom_factor + self.offset_y
|
||
canvas_x2 = img_x * self.zoom_factor + self.offset_x
|
||
canvas_y2 = img_y * self.zoom_factor + self.offset_y
|
||
|
||
self.current_rect = self.canvas.create_rectangle(
|
||
canvas_x1, canvas_y1, canvas_x2, canvas_y2,
|
||
outline="green", width=2, dash=(5, 5)
|
||
)
|
||
|
||
def on_mouse_up(self, event):
|
||
"""鼠标释放事件"""
|
||
if self.drawing:
|
||
# 完成绘制
|
||
img_x, img_y = self.canvas_to_image_coords(event.x, event.y)
|
||
|
||
# 确保坐标正确排序
|
||
x1 = min(self.start_x, img_x)
|
||
y1 = min(self.start_y, img_y)
|
||
x2 = max(self.start_x, img_x)
|
||
y2 = max(self.start_y, img_y)
|
||
|
||
# 检查矩形大小
|
||
if x2 - x1 > 5 and y2 - y1 > 5:
|
||
# 添加标注(只保存坐标,不保存标签)
|
||
self.annotations.append((x1, y1, x2, y2))
|
||
self.update_annotation_list()
|
||
self.display_image()
|
||
self.update_status(f"已添加标注 #{len(self.annotations)}")
|
||
else:
|
||
self.update_status("标注太小,已忽略")
|
||
|
||
# 清理绘制状态
|
||
self.drawing = False
|
||
if self.current_rect:
|
||
self.canvas.delete(self.current_rect)
|
||
self.current_rect = None
|
||
|
||
def on_pan_start(self, event):
|
||
"""开始平移"""
|
||
self.panning = True
|
||
self.pan_start_x = event.x
|
||
self.pan_start_y = event.y
|
||
|
||
def on_pan_move(self, event):
|
||
"""平移移动"""
|
||
if self.panning:
|
||
dx = event.x - self.pan_start_x
|
||
dy = event.y - self.pan_start_y
|
||
self.offset_x += dx
|
||
self.offset_y += dy
|
||
self.pan_start_x = event.x
|
||
self.pan_start_y = event.y
|
||
self.display_image()
|
||
|
||
def on_pan_end(self, event):
|
||
"""结束平移"""
|
||
self.panning = False
|
||
|
||
def update_annotation_list(self):
|
||
"""更新标注列表"""
|
||
self.annotation_listbox.delete(0, tk.END)
|
||
for idx, (x1, y1, x2, y2) in enumerate(self.annotations):
|
||
width = x2 - x1
|
||
height = y2 - y1
|
||
list_text = f"{idx + 1}. 矩形 ({width:.0f}x{height:.0f})"
|
||
self.annotation_listbox.insert(tk.END, list_text)
|
||
|
||
def on_annotation_select(self, event):
|
||
"""标注列表选择事件"""
|
||
selection = self.annotation_listbox.curselection()
|
||
if selection:
|
||
self.selected_annotation = selection[0]
|
||
self.display_image()
|
||
self.update_status(f"已选中标注 #{self.selected_annotation + 1}")
|
||
|
||
def delete_selected_annotation(self):
|
||
"""删除选中的标注"""
|
||
if self.selected_annotation >= 0 and self.selected_annotation < len(self.annotations):
|
||
del self.annotations[self.selected_annotation]
|
||
self.selected_annotation = -1
|
||
self.update_annotation_list()
|
||
self.display_image()
|
||
self.update_status("已删除选中标注")
|
||
|
||
def clear_current_annotation(self):
|
||
"""清除当前标注"""
|
||
self.selected_annotation = -1
|
||
self.display_image()
|
||
self.update_status("已清除选中状态")
|
||
|
||
def clear_all_annotations(self):
|
||
"""删除所有标注"""
|
||
if messagebox.askyesno("确认", "确定要删除所有标注吗?"):
|
||
self.annotations = []
|
||
self.selected_annotation = -1
|
||
self.update_annotation_list()
|
||
self.display_image()
|
||
self.update_status("已删除所有标注")
|
||
|
||
def save_annotations(self):
|
||
"""保存标注到文件(仅TXT格式,纯坐标,格式:top right bottom left)"""
|
||
if not self.current_image_path:
|
||
messagebox.showwarning("警告", "请先加载图片")
|
||
return
|
||
|
||
if not self.annotations:
|
||
if not messagebox.askyesno("确认", "没有标注要保存,是否继续?"):
|
||
return
|
||
|
||
try:
|
||
# 获取图像尺寸
|
||
img_width, img_height = self.image_pil.size
|
||
|
||
# 生成保存路径:图片文件名.txt(同目录下)
|
||
image_dir = os.path.dirname(self.current_image_path)
|
||
image_basename = os.path.splitext(os.path.basename(self.current_image_path))[0]
|
||
file_path = os.path.join(image_dir, f"{image_basename}.txt")
|
||
|
||
# 保存标注(纯坐标格式,top right bottom left)
|
||
with open(file_path, 'w', encoding='utf-8') as f:
|
||
for x1, y1, x2, y2 in self.annotations:
|
||
# 转换为百分比坐标(top, right, bottom, left)
|
||
top_pct = y1 / img_height
|
||
right_pct = x2 / img_width
|
||
bottom_pct = y2 / img_height
|
||
left_pct = x1 / img_width
|
||
|
||
# 写入坐标数据(保留6位小数,空格分隔)
|
||
f.write(f"{top_pct:.6f} {right_pct:.6f} {bottom_pct:.6f} {left_pct:.6f}\n")
|
||
|
||
self.update_status(f"已保存 {len(self.annotations)} 个标注到 {os.path.basename(file_path)}")
|
||
messagebox.showinfo("成功", f"标注已保存到:\n{file_path}\n\n格式: top right bottom left (百分比坐标)")
|
||
|
||
except Exception as e:
|
||
messagebox.showerror("错误", f"保存失败: {e}")
|
||
|
||
def load_annotations(self):
|
||
"""加载标注文件(TXT格式,纯坐标,格式:top right bottom left)"""
|
||
if not self.current_image_path:
|
||
messagebox.showwarning("警告", "请先加载图片")
|
||
return
|
||
|
||
# 默认加载同目录下的同名txt文件
|
||
image_dir = os.path.dirname(self.current_image_path)
|
||
image_basename = os.path.splitext(os.path.basename(self.current_image_path))[0]
|
||
default_file = os.path.join(image_dir, f"{image_basename}.txt")
|
||
|
||
if os.path.exists(default_file):
|
||
file_path = default_file
|
||
else:
|
||
file_path = filedialog.askopenfilename(
|
||
title="选择标注文件",
|
||
initialdir=image_dir,
|
||
filetypes=[
|
||
("文本文件", "*.txt"),
|
||
("所有文件", "*.*")
|
||
]
|
||
)
|
||
|
||
if not file_path:
|
||
return
|
||
|
||
try:
|
||
self.load_txt_annotations(file_path)
|
||
self.update_annotation_list()
|
||
self.display_image()
|
||
self.update_status(f"已加载 {len(self.annotations)} 个标注")
|
||
|
||
except Exception as e:
|
||
messagebox.showerror("错误", f"加载失败: {e}")
|
||
|
||
def load_txt_annotations(self, file_path):
|
||
"""加载TXT格式的标注文件(纯坐标格式,top right bottom left)"""
|
||
self.annotations = []
|
||
img_width, img_height = self.image_pil.size
|
||
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
for line_num, line in enumerate(f, 1):
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
|
||
parts = line.split()
|
||
if len(parts) == 4:
|
||
try:
|
||
top_pct = float(parts[0])
|
||
right_pct = float(parts[1])
|
||
bottom_pct = float(parts[2])
|
||
left_pct = float(parts[3])
|
||
|
||
# 验证百分比坐标范围
|
||
if all(0 <= v <= 1 for v in [top_pct, right_pct, bottom_pct, left_pct]):
|
||
# 转换为绝对坐标
|
||
x1 = left_pct * img_width
|
||
y1 = top_pct * img_height
|
||
x2 = right_pct * img_width
|
||
y2 = bottom_pct * img_height
|
||
|
||
self.annotations.append((x1, y1, x2, y2))
|
||
else:
|
||
print(f"警告: 第{line_num}行坐标超出范围 [0,1]: {line}")
|
||
except ValueError as e:
|
||
print(f"警告: 第{line_num}行格式错误: {line}, 错误: {e}")
|
||
else:
|
||
print(f"警告: 第{line_num}行格式错误,需要4个坐标值: {line}")
|
||
|
||
def update_info(self):
|
||
"""更新信息显示"""
|
||
if self.current_image_path and self.image_pil:
|
||
img_width, img_height = self.image_pil.size
|
||
filename = os.path.basename(self.current_image_path)
|
||
self.info_label.config(text=f"图片: {filename}\n尺寸: {img_width}x{img_height}\n标注: {len(self.annotations)}个")
|
||
else:
|
||
self.info_label.config(text="未加载图片")
|
||
|
||
def update_status(self, message=None):
|
||
"""更新状态栏"""
|
||
if message:
|
||
self.status_label.config(text=f"状态: {message}")
|
||
else:
|
||
self.status_label.config(text="状态: 就绪")
|
||
|
||
def update(self):
|
||
"""更新界面"""
|
||
self.update_info()
|
||
self.root.after(1000, self.update)
|
||
|
||
|
||
def main():
|
||
"""主函数"""
|
||
root = tk.Tk()
|
||
app = ImageAnnotationTool(root)
|
||
app.update()
|
||
root.mainloop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |