phone-detect-test 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """phone-detect-test:考场手机检测工具。
2
+
3
+ 一条命令启动监控台(网页 + 推理接口,同端口):
4
+ phone-detect-test web
5
+
6
+ 也可作为 CLI 工具,覆盖目标检测任务的完整生命周期:
7
+ 检测(detect)、训练(train)、评估(val)、数据集整理(prepare)、
8
+ 数据清洗(clean)、标签污染审计(audit)、正负样本重采样(resample)、
9
+ 离线评分(evaluate)、权重下载(download-weights)。
10
+ """
11
+
12
+ __version__ = "0.2.0"
@@ -0,0 +1,10 @@
1
+ # -*- coding: utf-8 -*-
2
+ """支持 `python -m phone_detect_test ...`,等价于 `phone-detect-test ...`。
3
+
4
+ 这样即使 pip 装的 Scripts 目录不在 PATH 里,也能用:
5
+ python -m phone_detect_test web
6
+ """
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -0,0 +1,155 @@
1
+ # -*- coding: utf-8 -*-
2
+ """audit:标签污染审计。
3
+
4
+ 这是本项目论文的核心方法:用「一个独立模型」回检已有标注,找出三类可疑样本——
5
+ 1. missing_gt :无标注但模型高置信检出 -> 可能漏标
6
+ 2. gt_unmatched :有标注但 GT 框没有任何预测匹配 -> 可能错误标注
7
+ 3. pred_unmatched:模型高置信检出但 GT 无对应 -> 可能漏标
8
+
9
+ 关键原则(来自论文结论):审计用的模型必须「独立于」当初做标注/清洗的模型,
10
+ 否则会陷入"自己验证自己"的循环,指标失真。
11
+ """
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ from .utils import IMG_EXTS, pick_device, resolve_weights
16
+
17
+
18
+ def _compute_iou(box1, box2):
19
+ """box = (xc, yc, w, h) 归一化。"""
20
+ x1min, y1min = box1[0] - box1[2] / 2, box1[1] - box1[3] / 2
21
+ x1max, y1max = box1[0] + box1[2] / 2, box1[1] + box1[3] / 2
22
+ x2min, y2min = box2[0] - box2[2] / 2, box2[1] - box2[3] / 2
23
+ x2max, y2max = box2[0] + box2[2] / 2, box2[1] + box2[3] / 2
24
+ ix1, iy1 = max(x1min, x2min), max(y1min, y2min)
25
+ ix2, iy2 = min(x1max, x2max), min(y1max, y2max)
26
+ inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
27
+ a1 = (x1max - x1min) * (y1max - y1min)
28
+ a2 = (x2max - x2min) * (y2max - y2min)
29
+ union = a1 + a2 - inter
30
+ return inter / union if union > 0 else 0.0
31
+
32
+
33
+ def _read_gt(label_path, class_id):
34
+ boxes = []
35
+ p = Path(label_path)
36
+ if not p.exists():
37
+ return boxes
38
+ for line in p.read_text(encoding="utf-8").splitlines():
39
+ parts = line.strip().split()
40
+ if len(parts) < 5:
41
+ continue
42
+ if int(parts[0]) != class_id:
43
+ continue
44
+ xc, yc, w, h = map(float, parts[1:5])
45
+ boxes.append((xc, yc, w, h))
46
+ return boxes
47
+
48
+
49
+ def run(args):
50
+ from ultralytics import YOLO
51
+
52
+ images_dir = Path(args.images)
53
+ labels_dir = Path(args.labels)
54
+ out_dir = Path(args.out_dir)
55
+ vis_dir = out_dir / "_pred_visual"
56
+ if not images_dir.exists():
57
+ raise SystemExit(f"图片目录不存在:{images_dir}")
58
+ if not labels_dir.exists():
59
+ raise SystemExit(f"标签目录不存在:{labels_dir}")
60
+ out_dir.mkdir(parents=True, exist_ok=True)
61
+ vis_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ weights, _ = resolve_weights(args.weights,
64
+ weights_url=getattr(args, "weights_url", None))
65
+ device = pick_device(args.device)
66
+ model = YOLO(weights)
67
+ print(f"[审计] 模型 {weights} | 设备 {device}")
68
+ print(f"[审计] 类别 id={args.class_id} | 独立回检阈值 conf>={args.conf_pred_no_gt}")
69
+
70
+ stats = {"total": 0, "missing_gt": 0, "gt_unmatched": 0, "pred_unmatched": 0}
71
+ records = []
72
+
73
+ for img_path in sorted(images_dir.iterdir()):
74
+ if img_path.suffix.lower() not in IMG_EXTS:
75
+ continue
76
+ stats["total"] += 1
77
+ label_path = labels_dir / f"{img_path.stem}.txt"
78
+
79
+ r = model(str(img_path), conf=args.conf, verbose=False)[0]
80
+ pred_boxes = []
81
+ for i in range(len(r.boxes)):
82
+ if int(r.boxes.cls[i]) != args.class_id:
83
+ continue
84
+ conf = float(r.boxes.conf[i])
85
+ xc, yc, w, h = r.boxes.xywhn[i].tolist()
86
+ pred_boxes.append((conf, xc, yc, w, h))
87
+
88
+ gt_boxes = _read_gt(label_path, args.class_id)
89
+ reason = None
90
+
91
+ if len(gt_boxes) == 0:
92
+ if any(p[0] >= args.conf_pred_no_gt for p in pred_boxes):
93
+ reason = "missing_gt"
94
+ stats["missing_gt"] += 1
95
+ else:
96
+ for gt in gt_boxes:
97
+ max_iou = max((_compute_iou(gt, p[1:]) for p in pred_boxes), default=0)
98
+ if max_iou < args.iou_gt_no_pred:
99
+ reason = "gt_unmatched"
100
+ stats["gt_unmatched"] += 1
101
+ break
102
+ if reason is None:
103
+ for conf, box in [ (p[0], p[1:]) for p in pred_boxes ]:
104
+ if conf >= args.conf_pred_no_gt:
105
+ max_iou = max((_compute_iou(box, gt) for gt in gt_boxes), default=0)
106
+ if max_iou < args.match_iou:
107
+ reason = "pred_unmatched"
108
+ stats["pred_unmatched"] += 1
109
+ break
110
+
111
+ if reason is not None:
112
+ records.append((img_path.name, reason))
113
+ shutil.copy2(img_path, out_dir / img_path.name)
114
+ if label_path.exists():
115
+ shutil.copy2(label_path, out_dir / label_path.name)
116
+ _draw_compare(img_path, gt_boxes, pred_boxes, vis_dir)
117
+
118
+ _write_summary(stats, records, out_dir)
119
+ print(f"\n[完成] 总图片 {stats['total']},可疑 {len(records)} 个")
120
+ print(f" 漏标 {stats['missing_gt']} | GT无法匹配 {stats['gt_unmatched']} | 预测无对应 {stats['pred_unmatched']}")
121
+ print(f" 输出目录:{out_dir}")
122
+
123
+
124
+ def _draw_compare(img_path, gt_boxes, pred_boxes, vis_dir):
125
+ import cv2
126
+ img = cv2.imread(str(img_path))
127
+ if img is None:
128
+ return
129
+ h, w = img.shape[:2]
130
+ for (xc, yc, bw, bh) in gt_boxes:
131
+ x1, y1 = int((xc - bw / 2) * w), int((yc - bh / 2) * h)
132
+ x2, y2 = int((xc + bw / 2) * w), int((yc + bh / 2) * h)
133
+ cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
134
+ for (conf, xc, yc, bw, bh) in pred_boxes:
135
+ x1, y1 = int((xc - bw / 2) * w), int((yc - bh / 2) * h)
136
+ x2, y2 = int((xc + bw / 2) * w), int((yc + bh / 2) * h)
137
+ cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
138
+ cv2.putText(img, f"{conf:.2f}", (x1, max(0, y1 - 5)),
139
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
140
+ cv2.imwrite(str(vis_dir / img_path.name), img)
141
+
142
+
143
+ def _write_summary(stats, records, out_dir):
144
+ p = out_dir / "audit_report.txt"
145
+ with open(p, "w", encoding="utf-8") as f:
146
+ f.write("标签污染审计报告\n" + "=" * 40 + "\n")
147
+ f.write(f"总图片: {stats['total']}\n")
148
+ f.write(f"可疑总数: {len(records)}\n")
149
+ f.write(f" - 缺失标注(漏标): {stats['missing_gt']}\n")
150
+ f.write(f" - GT无法匹配(错框): {stats['gt_unmatched']}\n")
151
+ f.write(f" - 预测无对应(漏标): {stats['pred_unmatched']}\n\n")
152
+ f.write("可疑图片列表:\n")
153
+ for name, reason in records:
154
+ f.write(f"{name}\t{reason}\n")
155
+ print(f"[报告] {p}")
@@ -0,0 +1,246 @@
1
+ # -*- coding: utf-8 -*-
2
+ """clean:一键清洗目标检测数据集。
3
+
4
+ 包含:图片完整性检查、格式修正、越界裁剪、过小/异常宽高比过滤、
5
+ 去重、基于预训练模型的漏标/误标检测、可视化抽查、清洗报告。
6
+ """
7
+ import json
8
+ import os
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ def _iou(box1, box2):
15
+ """box = [x1, y1, x2, y2](归一化)。"""
16
+ x1 = max(box1[0], box2[0])
17
+ y1 = max(box1[1], box2[1])
18
+ x2 = min(box1[2], box2[2])
19
+ y2 = min(box1[3], box2[3])
20
+ inter = max(0, x2 - x1) * max(0, y2 - y1)
21
+ a1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
22
+ a2 = (box2[2] - box2[0]) * (box2[3] - box2[1])
23
+ union = a1 + a2 - inter
24
+ return inter / union if union else 0.0
25
+
26
+
27
+ def _parse_label(txt_path):
28
+ boxes = []
29
+ p = Path(txt_path)
30
+ if not p.exists() or p.stat().st_size == 0:
31
+ return boxes
32
+ for line in p.read_text(encoding="utf-8").splitlines():
33
+ line = line.strip()
34
+ if not line:
35
+ continue
36
+ parts = line.split()
37
+ if len(parts) != 5:
38
+ continue
39
+ cls = int(parts[0])
40
+ cx, cy, w, h = map(float, parts[1:5])
41
+ boxes.append([cls, cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2])
42
+ return boxes
43
+
44
+
45
+ def _save_label(txt_path, boxes):
46
+ with open(txt_path, "w") as f:
47
+ for cls, x1, y1, x2, y2 in boxes:
48
+ cx = min(max((x1 + x2) / 2, 0), 1)
49
+ cy = min(max((y1 + y2) / 2, 0), 1)
50
+ w = min(max(x2 - x1, 0), 1)
51
+ h = min(max(y2 - y1, 0), 1)
52
+ f.write(f"{cls} {cx:.8f} {cy:.8f} {w:.8f} {h:.8f}\n")
53
+
54
+
55
+ def _normalize(boxes):
56
+ valid = []
57
+ for cls, x1, y1, x2, y2 in boxes:
58
+ if x2 <= x1 or y2 <= y1:
59
+ continue
60
+ x1 = max(0, min(x1, 1))
61
+ y1 = max(0, min(y1, 1))
62
+ x2 = max(0, min(x2, 1))
63
+ y2 = max(0, min(y2, 1))
64
+ if x2 <= x1 or y2 <= y1:
65
+ continue
66
+ valid.append([cls, x1, y1, x2, y2])
67
+ return valid
68
+
69
+
70
+ def run(args):
71
+ from PIL import Image
72
+ from ultralytics import YOLO
73
+
74
+ data_dir = Path(args.data_dir)
75
+ output_dir = Path(args.output_dir)
76
+ images_dir = data_dir / "images"
77
+ labels_dir = data_dir / "labels"
78
+ out_images = output_dir / "images"
79
+ out_labels = output_dir / "labels"
80
+
81
+ if not images_dir.exists():
82
+ raise SystemExit(f"图片目录不存在:{images_dir}")
83
+
84
+ out_images.mkdir(parents=True, exist_ok=True)
85
+ out_labels.mkdir(parents=True, exist_ok=True)
86
+
87
+ model = YOLO(args.model_path)
88
+ print(f"[模型] {args.model_path}")
89
+
90
+ stats = {
91
+ "total": 0, "empty": 0, "non_empty": 0, "orig_boxes": 0,
92
+ "cleaned_boxes": 0, "removed_boxes": 0, "empty_after_clean": 0,
93
+ "broken": 0, "missing_candidates": 0, "false_candidates": 0,
94
+ }
95
+ missing_list, false_list, broken_list = [], [], []
96
+ viz_samples = []
97
+
98
+ image_files = sorted(images_dir.glob("*.jpg"))
99
+ stats["total"] = len(image_files)
100
+ print(f"[开始] 清洗 {len(image_files)} 张图片")
101
+
102
+ for idx, img_path in enumerate(image_files):
103
+ label_path = labels_dir / f"{img_path.stem}.txt"
104
+
105
+ # 图片完整性
106
+ try:
107
+ with Image.open(img_path) as im:
108
+ im.verify()
109
+ except Exception as e:
110
+ stats["broken"] += 1
111
+ broken_list.append(str(img_path))
112
+ continue
113
+
114
+ raw = _parse_label(label_path)
115
+ stats["orig_boxes"] += len(raw)
116
+ cleaned = []
117
+
118
+ if len(raw) == 0:
119
+ stats["empty"] += 1
120
+ # 负样本:检查漏标
121
+ if args.check_missing:
122
+ r = model(str(img_path), conf=args.missing_conf, verbose=False)[0]
123
+ for box in r.boxes:
124
+ if int(box.cls[0]) == args.phone_class_id and float(box.conf[0]) >= args.missing_conf:
125
+ x1, y1, x2, y2 = box.xyxy[0].tolist()
126
+ with Image.open(img_path) as im:
127
+ w, h = im.size
128
+ missing_list.append({
129
+ "image": str(img_path),
130
+ "box": [x1 / w, y1 / h, x2 / w, y2 / h],
131
+ "conf": float(box.conf[0]),
132
+ })
133
+ stats["missing_candidates"] += 1
134
+ shutil.copy2(img_path, out_images / img_path.name)
135
+ (out_labels / f"{img_path.stem}.txt").touch()
136
+ else:
137
+ stats["non_empty"] += 1
138
+ fixed = _normalize(raw)
139
+ # 过滤过小 / 异常宽高比
140
+ kept = []
141
+ for cls, x1, y1, x2, y2 in fixed:
142
+ w = x2 - x1
143
+ h = y2 - y1
144
+ area = w * h
145
+ aspect = max(w, h) / max(min(w, h), 1e-6)
146
+ if area < args.min_area or min(w, h) < args.min_side:
147
+ continue
148
+ if aspect < args.aspect_min or aspect > args.aspect_max:
149
+ continue
150
+ kept.append([cls, x1, y1, x2, y2])
151
+ # 去重
152
+ dedup = []
153
+ for box in kept:
154
+ if any(_iou(box[1:], k[1:]) > args.dedup_iou for k in dedup):
155
+ continue
156
+ dedup.append(box)
157
+ # 误标检查
158
+ if args.check_false:
159
+ cleaned = _check_false(model, img_path, dedup, args.phone_class_id,
160
+ args.false_conf, false_list)
161
+ stats["false_candidates"] += len(false_list)
162
+ else:
163
+ cleaned = dedup
164
+
165
+ stats["removed_boxes"] += len(raw) - len(cleaned)
166
+ if len(cleaned) == 0:
167
+ stats["empty_after_clean"] += 1
168
+ shutil.copy2(img_path, out_images / img_path.name)
169
+ _save_label(out_labels / f"{img_path.stem}.txt", cleaned)
170
+
171
+ stats["cleaned_boxes"] += len(cleaned)
172
+ if len(viz_samples) < args.visualize_n:
173
+ viz_samples.append((img_path, cleaned))
174
+ if (idx + 1) % 1000 == 0:
175
+ print(f" 已处理 {idx + 1}/{len(image_files)}")
176
+
177
+ _visualize(viz_samples, output_dir)
178
+ _report(stats, missing_list, false_list, broken_list, output_dir)
179
+
180
+
181
+ def _check_false(model, img_path, gt_boxes, phone_class_id, false_conf, false_list):
182
+ """用预训练模型检查 GT 框是否为真目标,低置信且无匹配则判误标删除。"""
183
+ from PIL import Image
184
+ r = model(str(img_path), conf=false_conf, verbose=False)[0]
185
+ with Image.open(img_path) as im:
186
+ w, h = im.size
187
+ pred_boxes = []
188
+ for box in r.boxes:
189
+ if int(box.cls[0]) != phone_class_id:
190
+ continue
191
+ x1, y1, x2, y2 = box.xyxy[0].tolist()
192
+ pred_boxes.append([x1 / w, y1 / h, x2 / w, y2 / h, float(box.conf[0])])
193
+
194
+ kept = []
195
+ for gt in gt_boxes:
196
+ gx1, gy1, gx2, gy2 = gt[1:]
197
+ max_iou = max((_iou([gx1, gy1, gx2, gy2], pb[:4]) for pb in pred_boxes), default=0)
198
+ max_conf = max((pb[4] for pb in pred_boxes if _iou([gx1, gy1, gx2, gy2], pb[:4]) > 0.5), default=0)
199
+ if max_iou < 0.1 and max_conf < false_conf:
200
+ false_list.append({"image": str(img_path), "gt_box": [gx1, gy1, gx2, gy2], "max_conf": max_conf})
201
+ continue
202
+ kept.append(gt)
203
+ return kept
204
+
205
+
206
+ def _visualize(samples, output_dir):
207
+ if not samples:
208
+ return
209
+ from PIL import Image
210
+ import numpy as np
211
+ from ultralytics.utils.plotting import Annotator
212
+ viz_dir = output_dir / "visualization"
213
+ viz_dir.mkdir(exist_ok=True)
214
+ for i, (img_path, boxes) in enumerate(samples):
215
+ img = Image.open(img_path).convert("RGB")
216
+ annotator = Annotator(np.array(img))
217
+ for cls, x1, y1, x2, y2 in boxes:
218
+ w, h = img.size
219
+ annotator.box_label([x1 * w, y1 * h, x2 * w, y2 * h], str(cls), color=(0, 255, 0))
220
+ Image.fromarray(annotator.result()).save(viz_dir / f"sample_{i:03d}.jpg")
221
+ print(f"[可视化] 已保存 {len(samples)} 张至 {viz_dir}")
222
+
223
+
224
+ def _report(stats, missing_list, false_list, broken_list, output_dir):
225
+ report = {
226
+ "清洗统计": {
227
+ "总图片数": stats["total"],
228
+ "空标签图片数": stats["empty"],
229
+ "有标注图片数": stats["non_empty"],
230
+ "清洗前标注框总数": stats["orig_boxes"],
231
+ "清洗后标注框总数": stats["cleaned_boxes"],
232
+ "删除标注框数": stats["removed_boxes"],
233
+ "清洗后变空的图片数": stats["empty_after_clean"],
234
+ "损坏图片数": stats["broken"],
235
+ "疑似漏标数": stats["missing_candidates"],
236
+ "疑似误标数": stats["false_candidates"],
237
+ },
238
+ "疑似漏标": missing_list[:100],
239
+ "疑似误标": false_list[:100],
240
+ "损坏图片": broken_list,
241
+ }
242
+ p = output_dir / "clean_report.json"
243
+ p.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
244
+ print(f"\n[报告] {p}")
245
+ for k, v in report["清洗统计"].items():
246
+ print(f" {k}: {v}")
@@ -0,0 +1,184 @@
1
+ # -*- coding: utf-8 -*-
2
+ """phone-detect-test 命令行入口。
3
+
4
+ 用法示例:
5
+ phone-detect-test web
6
+ phone-detect-test download-weights
7
+ phone-detect-test detect --source imgs/ --output out.json
8
+ phone-detect-test detect --source img.jpg --draw --draw-dir vis/
9
+ phone-detect-test train --data data.yaml --epochs 100
10
+ phone-detect-test val --data data.yaml --sweep
11
+ phone-detect-test prepare --src raw/ --out dataset/
12
+ phone-detect-test clean --data-dir raw/ --output-dir cleaned/
13
+ phone-detect-test audit --images train/images --labels train/labels --out-dir suspicious/
14
+ phone-detect-test resample --data-dir dataset/ --output-dir resampled/
15
+ phone-detect-test evaluate --pred pred.json --images gt/images --labels gt/labels
16
+ """
17
+ import argparse
18
+ import sys
19
+
20
+ # 需要模型权重的子命令都支持这两个通用参数
21
+ _WEIGHTS_ARGS = ("--weights", "--weights-url")
22
+
23
+
24
+ def _add_weights_args(p, url_help="覆盖权重下载地址(优先级高于环境变量与内置默认值)"):
25
+ """给子命令加上统一的权重相关参数。"""
26
+ p.add_argument("--weights", default=None, help="模型权重路径(默认自动发现/下载)")
27
+ p.add_argument("--weights-url", default=None, help=url_help)
28
+
29
+
30
+ def main(argv=None):
31
+ parser = argparse.ArgumentParser(
32
+ prog="phone-detect-test",
33
+ description="考场手机检测 CLI 工具:检测 / 训练 / 数据清洗 / 标签审计",
34
+ )
35
+ sub = parser.add_subparsers(dest="command", required=True)
36
+
37
+ # ---- detect ----
38
+ p = sub.add_parser("detect", help="单图 / 目录批量检测")
39
+ p.add_argument("--source", required=True, help="图片路径或目录")
40
+ _add_weights_args(p)
41
+ p.add_argument("--output", default=None, help="输出 JSON 路径")
42
+ p.add_argument("--conf", type=float, default=0.25, help="置信度阈值")
43
+ p.add_argument("--iou", type=float, default=0.7, help="NMS IoU 阈值")
44
+ p.add_argument("--imgsz", type=int, default=640, help="推理分辨率")
45
+ p.add_argument("--device", default=None, help="设备,默认自动")
46
+ p.add_argument("--class-name", default=None, help="类别名(默认取模型名)")
47
+ p.add_argument("--draw", action="store_true", help="画框输出可视化图")
48
+ p.add_argument("--draw-dir", default="vis", help="可视化输出目录")
49
+ p.set_defaults(func="detect")
50
+
51
+ # ---- train ----
52
+ p = sub.add_parser("train", help="继续训练 / 微调")
53
+ p.add_argument("--data", required=True, help="数据集 data.yaml")
54
+ _add_weights_args(p, url_help="覆盖初始权重的下载地址")
55
+ p.add_argument("--epochs", type=int, default=100)
56
+ p.add_argument("--batch", type=int, default=16)
57
+ p.add_argument("--imgsz", type=int, default=640)
58
+ p.add_argument("--device", default=None)
59
+ p.add_argument("--workers", type=int, default=4)
60
+ p.add_argument("--patience", type=int, default=30)
61
+ p.add_argument("--optimizer", default="auto")
62
+ p.add_argument("--lr0", type=float, default=0.001)
63
+ p.add_argument("--lrf", type=float, default=0.01)
64
+ p.add_argument("--freeze", type=int, default=None)
65
+ p.add_argument("--close-mosaic", type=int, default=10)
66
+ p.add_argument("--cache", default="False", choices=["False", "ram", "disk"])
67
+ p.add_argument("--seed", type=int, default=42)
68
+ p.add_argument("--project", default="runs/detect")
69
+ p.add_argument("--name", default="phone_ft")
70
+ p.add_argument("--resume", default=None)
71
+ p.set_defaults(func="train")
72
+
73
+ # ---- val ----
74
+ p = sub.add_parser("val", help="验证集评估 + 阈值搜索")
75
+ p.add_argument("--data", required=True, help="数据集 data.yaml")
76
+ _add_weights_args(p)
77
+ p.add_argument("--split", default="val")
78
+ p.add_argument("--imgsz", type=int, default=640)
79
+ p.add_argument("--batch", type=int, default=16)
80
+ p.add_argument("--conf", type=float, default=0.25)
81
+ p.add_argument("--iou", type=float, default=0.7)
82
+ p.add_argument("--device", default=None)
83
+ p.add_argument("--workers", type=int, default=0)
84
+ p.add_argument("--sweep", action="store_true", help="搜索最优 conf 阈值")
85
+ p.add_argument("--conf-grid", default="0.1,0.2,0.25,0.3,0.4,0.5,0.6,0.7")
86
+ p.add_argument("--project", default="runs")
87
+ p.set_defaults(func="val")
88
+
89
+ # ---- prepare ----
90
+ p = sub.add_parser("prepare", help="整理数据集并生成 data.yaml")
91
+ p.add_argument("--src", default=None, help="图片+标签混合文件夹")
92
+ p.add_argument("--images", default=None)
93
+ p.add_argument("--labels", default=None)
94
+ p.add_argument("--out", required=True, help="输出数据集根目录")
95
+ p.add_argument("--val-ratio", type=float, default=0.1)
96
+ p.add_argument("--seed", type=int, default=42)
97
+ p.add_argument("--class-name", default="phone")
98
+ p.set_defaults(func="prepare")
99
+
100
+ # ---- clean ----
101
+ p = sub.add_parser("clean", help="一键清洗数据集")
102
+ p.add_argument("--data-dir", required=True, help="输入数据集(含 images/ labels/)")
103
+ p.add_argument("--output-dir", required=True, help="清洗后输出目录")
104
+ p.add_argument("--model-path", default="yolov8n.pt", help="清洗用预训练模型")
105
+ p.add_argument("--phone-class-id", type=int, default=67, help="预训练模型里手机的类别 id")
106
+ p.add_argument("--missing-conf", type=float, default=0.7)
107
+ p.add_argument("--false-conf", type=float, default=0.05)
108
+ p.add_argument("--min-area", type=float, default=1e-5)
109
+ p.add_argument("--min-side", type=float, default=0.01)
110
+ p.add_argument("--aspect-min", type=float, default=0.1)
111
+ p.add_argument("--aspect-max", type=float, default=10.0)
112
+ p.add_argument("--dedup-iou", type=float, default=0.8)
113
+ p.add_argument("--visualize-n", type=int, default=20)
114
+ p.add_argument("--check-missing", action="store_true", help="检查漏标")
115
+ p.add_argument("--check-false", action="store_true", help="检查误标")
116
+ p.set_defaults(func="clean")
117
+
118
+ # ---- audit ----
119
+ p = sub.add_parser("audit", help="标签污染审计")
120
+ p.add_argument("--images", required=True, help="图片目录")
121
+ p.add_argument("--labels", required=True, help="标签目录")
122
+ p.add_argument("--out-dir", default="suspicious", help="输出目录")
123
+ _add_weights_args(p)
124
+ p.add_argument("--class-id", type=int, default=0)
125
+ p.add_argument("--conf", type=float, default=0.25)
126
+ p.add_argument("--conf-pred-no-gt", type=float, default=0.5)
127
+ p.add_argument("--iou-gt-no-pred", type=float, default=0.1)
128
+ p.add_argument("--match-iou", type=float, default=0.1)
129
+ p.add_argument("--device", default=None)
130
+ p.set_defaults(func="audit")
131
+
132
+ # ---- resample ----
133
+ p = sub.add_parser("resample", help="正负样本重采样")
134
+ p.add_argument("--data-dir", required=True, help="输入数据集(含 images/ labels/)")
135
+ p.add_argument("--output-dir", required=True, help="输出目录")
136
+ p.add_argument("--pos-ratio", type=float, default=0.5, help="正样本占负样本比例(默认 0.5 即 1:2)")
137
+ p.add_argument("--seed", type=int, default=42)
138
+ p.set_defaults(func="resample")
139
+
140
+ # ---- evaluate ----
141
+ p = sub.add_parser("evaluate", help="离线评测(复刻赛题评分)")
142
+ p.add_argument("--pred", required=True, help="detect 输出的 JSON")
143
+ p.add_argument("--images", required=True, help="真值图片目录")
144
+ p.add_argument("--labels", required=True, help="真值标签目录")
145
+ p.add_argument("--conf", type=float, default=0.25)
146
+ p.add_argument("--iou", type=float, default=0.5)
147
+ p.set_defaults(func="evaluate")
148
+
149
+ # ---- download-weights ----
150
+ p = sub.add_parser(
151
+ "download-weights",
152
+ help="只下载权重并校验可用(发完 Release 后用它验证地址是否生效)")
153
+ # --url 与 --weights-url 等价,和其它子命令保持一致的写法
154
+ p.add_argument("--url", "--weights-url", dest="url", default=None,
155
+ help="覆盖下载地址(优先级最高)")
156
+ p.add_argument("--dest", default="phone_best.pt", help="保存路径,默认 ./phone_best.pt")
157
+ p.add_argument("--force", action="store_true", help="即使本机已有权重也重新下载")
158
+ p.add_argument("--no-verify", action="store_true", help="只下载,不用模型加载校验")
159
+ p.set_defaults(func="download_weights")
160
+
161
+ # ---- web:一条命令启动监控台(页面 + 推理接口,同端口)----
162
+ p = sub.add_parser(
163
+ "web", help="启动监控台网页 + 推理接口,并自动打开浏览器(最常用入口)")
164
+ _add_weights_args(p)
165
+ p.add_argument("--host", default="127.0.0.1",
166
+ help="监听地址;局域网访问用 0.0.0.0")
167
+ p.add_argument("--port", type=int, default=8080,
168
+ help="端口,默认 8080(被占用会自动顺延)")
169
+ p.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
170
+ p.add_argument("--device", default=None, help="设备,如 cuda:0 / cpu")
171
+ p.add_argument("--imgsz", type=int, default=None, help="推理分辨率")
172
+ p.add_argument("--conf", type=float, default=None, help="默认置信度阈值")
173
+ p.add_argument("--reload", action="store_true", help="开发模式:改代码自动重启")
174
+ p.set_defaults(func="server")
175
+
176
+ args = parser.parse_args(argv)
177
+
178
+ # 分发到对应模块
179
+ module = __import__(f"phone_detect_test.{args.func}", fromlist=["run"])
180
+ module.run(args)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()