From 154677032ff8014d4df78c7494f10c9dd1c6d5db Mon Sep 17 00:00:00 2001 From: Shaun Zhou Date: Fri, 18 Sep 2026 15:34:13 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"=E7=BA=A6=E6=9D=9F=E9=A2=84=E6=B5=8B?= =?UTF-8?q?=E6=A1=86=E4=BA=92=E4=B8=8D=E9=87=8D=E5=8F=A0"=EF=BC=88eba533a?= =?UTF-8?q?=E3=80=819439cbb=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉强制非重叠:detect.py 不再给 DetectionValidator.postprocess 打补丁, 配置里移除 train.max_overlap 与 agnostic_nms,可视化脚本恢复为直接使用模型原始输出, engine/detection 里的 suppress_overlaps / filter_overlapping_outputs 一并移除。 约束会把 mAP50 从 0.555 压到 0.483(precision 0.536->0.612),代价是按置信度抑制时 高分错框会压掉本可命中的低分框。需要时用 git revert 这次提交即可取回。 按类别分目录的可视化产出结构(36fff98)保留。 Co-Authored-By: Claude Opus 5 --- configs/detect/film.yaml | 7 -- detect.py | 33 +-------- engine/detection.py | 78 --------------------- scripts/visualize_detections.py | 44 +----------- tests/test_detection.py | 116 -------------------------------- 5 files changed, 5 insertions(+), 273 deletions(-) diff --git a/configs/detect/film.yaml b/configs/detect/film.yaml index cd90ee3..1d87720 100644 --- a/configs/detect/film.yaml +++ b/configs/detect/film.yaml @@ -48,13 +48,6 @@ train: hsv_s: 0.3 hsv_v: 0.3 - # 真值里一处缺陷只有一个框、零重叠(实测 11211 张图、151 张多框图,重叠为 0), - # 所以验证时也把预测约束成互不重叠:早停与 best.pt 的选择才和最终推理一致。 - # 负数=不约束。注意这不是 ultralytics 的参数,由 detect.py 自己实现。 - max_overlap: 0.0 - # 端到端头的 top-k 每个 anchor 只取一个类别,压掉同一处缺陷被两个类别各框一次 - agnostic_nms: true - plots: true # results.png / PR 曲线 / 混淆矩阵 / val_batch 预测图 save_period: -1 diff --git a/detect.py b/detect.py index ef33020..2b33e14 100644 --- a/detect.py +++ b/detect.py @@ -6,11 +6,8 @@ --checkpoint runs/detect/film/weights/best.pt 数据是在 Label Studio 里修正过的标签,导出并划分 train/val 之后放在 data/ 下的数据集。 -框来自 Grad-CAM / SHAP 的弱监督标注再经人工修正。 - -**预测框约束为互不重叠**(`train.max_overlap`,默认 0)。真值里一处缺陷只有一个框、 -零重叠,模型却会输出重叠框,所以验证阶段补一层抑制——早停与 best.pt 的选择都据此进行, -指标才和最终推理一致。设为负数可关掉。 +框来自 Grad-CAM / SHAP 的弱监督标注,不是人工标注——指标该按"与热力区域的一致性" +来读,而不是当成真值上的检测精度。 """ from __future__ import annotations @@ -22,7 +19,6 @@ from pathlib import Path import yaml -from engine.detection import filter_overlapping_outputs from utils import load_config, seed_everything, setup_logging from utils.device import DeviceNotAvailableError, describe_device, resolve_device @@ -39,23 +35,6 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def install_overlap_suppression(max_overlap: float) -> None: - """给验证器的后处理包一层"框不重叠"约束。 - - 只能用打补丁的方式:ultralytics 没有对应的开关——端到端模型(yolo26)的 NMS 早退, - 传入的 iou / agnostic_nms 都不参与 IoU 抑制(见 engine/detection.filter_overlapping_outputs - 的说明)。补在 postprocess 之后,训练期验证、早停、best.pt 的选择就都遵守这条约束。 - """ - from ultralytics.models.yolo.detect import DetectionValidator - - original = DetectionValidator.postprocess - - def postprocess(self, preds): - return filter_overlapping_outputs(original(self, preds), max_iou=max_overlap) - - DetectionValidator.postprocess = postprocess - - def to_ultralytics_device(device) -> str | int: """torch.device -> ultralytics 的 device 记法(cuda 用序号,其余用字符串)。""" if device.type == "cuda": @@ -100,18 +79,10 @@ def main() -> int: train_cfg = dict(cfg.get("train", {})) swanlab_cfg = dict(train_cfg.pop("swanlab", {})) - # 不是 ultralytics 的参数,得先摘出来,否则它会拒绝未知键 - max_overlap = float(train_cfg.pop("max_overlap", 0.0)) from ultralytics import YOLO from ultralytics import settings as ul_settings - if max_overlap >= 0: - install_overlap_suppression(max_overlap) - logger.info("验证时约束预测框互不重叠(最大 IoU %.2f)", max_overlap) - else: - logger.warning("未约束预测框重叠:指标里会计入真值中不可能出现的重叠框") - # ultralytics 默认把数据集根指到 ~/datasets 之类的全局目录;我们的 data.yaml 里写的是 # 绝对路径,但它仍会拿 datasets_dir 去拼相对路径,统一指到项目根省得踩坑。 ul_settings.update({"datasets_dir": str(Path.cwd())}) diff --git a/engine/detection.py b/engine/detection.py index bb5defa..3fd01f3 100644 --- a/engine/detection.py +++ b/engine/detection.py @@ -102,84 +102,6 @@ def match_boxes( ) -def suppress_overlaps( - boxes: Sequence[Box], max_iou: float = 0.0, contained: bool = True -) -> list[int]: - """贪心地挑出互不重叠的框,返回保留下来的下标(按输入顺序)。 - - 按置信度从高到低考察,与任一已保留框重叠超过 `max_iou` 就丢弃。 - - 为什么需要这一步:NMS 只在**同类**内、且按固定 IoU 阈值抑制,所以同一处缺陷被两个 - 类别各框一次(跨类重复),或两个框 IoU 低于 NMS 阈值时,都会留下重叠框。而人工标注的 - 真值里一处缺陷只有一个框,零重叠——推理侧不加这层约束,指标和图上都会出现真值里 - 不可能有的形态。 - - Args: - max_iou: 允许的最大 IoU。0 表示碰到就算重叠,完全不允许。 - contained: 是否额外丢弃被已保留框完全包住的框。整图大框套小框时 IoU 可能很小 - (面积悬殊),但那显然是同一处目标的重复表达。 - """ - order = sorted(range(len(boxes)), key=lambda i: -(boxes[i].score or 0.0)) - kept: list[int] = [] - for index in order: - box = boxes[index] - if any( - iou(box, boxes[other]) > max_iou or (contained and _inside(box, boxes[other])) - for other in kept - ): - continue - kept.append(index) - return sorted(kept) - - -def _inside(inner: Box, outer: Box) -> bool: - """inner 是否完全落在 outer 里(含边界)。""" - return ( - outer.x1 <= inner.x1 and outer.y1 <= inner.y1 - and outer.x2 >= inner.x2 and outer.y2 >= inner.y2 - ) - - -def filter_overlapping_outputs( - outputs: list[dict], max_iou: float = 0.0, contained: bool = True -) -> list[dict]: - """给 ultralytics 验证器 postprocess 的输出补上"框不重叠"约束。 - - 每个元素是一张图的结果:`bboxes`(xyxy)、`conf`、`cls` 等张量。坐标是不是归一化的 - 无所谓,IoU 与尺度无关。 - - 为什么要在这里补:yolo26 这类**端到端模型不走 IoU 抑制**。ultralytics 的 - `non_max_suppression` 对它们早退(`utils/nms.py` 里 `prediction.shape[-1] == 6 or end2end` - 的分支),只按置信度与 `max_det` 截断,传入的 `iou` 和 `agnostic` 都不生效; - `agnostic_nms` 只作用于检测头的 top-k(每个 anchor 取一个类别),压不掉不同 anchor - 产生的重叠框。不补这一步,训练期的验证指标就和最终推理对不上。 - """ - filtered = [] - for output in outputs: - boxes = [ - Box(int(class_id), *(float(v) for v in xyxy), float(score)) - for xyxy, score, class_id in zip( - output["bboxes"].tolist(), output["conf"].tolist(), output["cls"].tolist(), - strict=True, - ) - ] - kept = suppress_overlaps(boxes, max_iou=max_iou, contained=contained) - if len(kept) == len(boxes): - filtered.append(output) - continue - # 张量用列表下标筛选;空结果不能用 [] 索引,改用零长切片 - filtered.append({ - key: (value[kept] if kept else value[:0]) if _is_per_box(value, len(boxes)) else value - for key, value in output.items() - }) - return filtered - - -def _is_per_box(value, count: int) -> bool: - """这个张量是不是"每个框一行"——只有这种才跟着筛选。""" - return hasattr(value, "shape") and len(getattr(value, "shape", ())) >= 1 and len(value) == count - - def draw_boxes(image, ground_truth: Sequence[Box], predictions: Sequence[Box], classes: Sequence[str], match: Match, width: int = 2): """把真值与预测画在图上(就地修改传入的 PIL 图像并返回)。 diff --git a/scripts/visualize_detections.py b/scripts/visualize_detections.py index df94802..4af07d9 100644 --- a/scripts/visualize_detections.py +++ b/scripts/visualize_detections.py @@ -25,10 +25,6 @@ train / val 不分目录,但 `index.csv` 里有 `split` 列——训练集的 同样地,"命中 / 漏检 / 误检"记在 `index.csv` 的 `outcome` 列里,按它排序筛选即可。 `--only errors` 只输出有漏检或误检的图,扫起来快得多。 - -**预测框默认约束为互不重叠**(`--max-overlap 0`)。人工标注的真值里一处缺陷只有一个框、 -零重叠,而 NMS 只在同类内按固定阈值抑制,留得下跨类重复和 IoU 低于阈值的重叠框。 -`--max-overlap -1` 可关掉这层约束,看模型的原始输出。 """ from __future__ import annotations @@ -46,13 +42,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import yaml # noqa: E402 from datasets.yolo import IMAGE_SUFFIXES # noqa: E402 -from engine.detection import ( # noqa: E402 - Box, - draw_boxes, - match_boxes, - parse_label, - suppress_overlaps, -) +from engine.detection import Box, draw_boxes, match_boxes, parse_label # noqa: E402 from utils import setup_logging # noqa: E402 from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402 @@ -133,16 +123,6 @@ def main() -> int: parser.add_argument("--imgsz", type=int, default=640) parser.add_argument("--conf", type=float, default=0.25, help="置信度阈值") parser.add_argument("--iou-threshold", type=float, default=0.5, help="判定命中的 IoU") - parser.add_argument( - "--max-overlap", type=float, default=0.0, - help="预测框之间允许的最大 IoU,超出的丢低分框。0=完全不允许重叠(默认,与真值一致)," - "-1=不约束,看模型原始输出", - ) - parser.add_argument("--nms-iou", type=float, default=0.7, help="NMS 的 IoU 阈值") - parser.add_argument( - "--no-agnostic-nms", action="store_true", - help="关掉类别无关 NMS。默认开启,用来压掉同一处缺陷被两个类别各框一次的重复", - ) parser.add_argument("--batch", type=int, default=16) parser.add_argument( "--only", choices=("all", "errors"), default="all", @@ -170,12 +150,7 @@ def main() -> int: "数据集: %s(%d 类,%s)", args.data, len(classes), ",".join(f"{name} {len(images)} 张" for name, images in splits.items()), ) - logger.info( - "conf=%.2f 命中 IoU=%.2f imgsz=%d NMS IoU=%.2f 类别无关 NMS=%s 框间最大重叠=%s", - args.conf, args.iou_threshold, args.imgsz, args.nms_iou, - not args.no_agnostic_nms, - "不约束" if args.max_overlap < 0 else f"{args.max_overlap:.2f}", - ) + logger.info("conf=%.2f IoU=%.2f imgsz=%d", args.conf, args.iou_threshold, args.imgsz) if out_dir.exists() and any(out_dir.iterdir()): if not args.overwrite: @@ -196,7 +171,6 @@ def main() -> int: scores: dict[str, list[float]] = defaultdict(list) rows = [] processed = 0 - suppressed = 0 def predict_in_batches(images): """自己按批切分:ultralytics 的 predict 不理会 batch 参数, @@ -205,8 +179,7 @@ def main() -> int: chunk = images[start:start + args.batch] results = model.predict( source=[str(p) for p in chunk], imgsz=args.imgsz, conf=args.conf, - device=device_arg, verbose=False, iou=args.nms_iou, - agnostic_nms=not args.no_agnostic_nms, + device=device_arg, verbose=False, ) yield from zip(chunk, results, strict=True) @@ -219,11 +192,6 @@ def main() -> int: result.boxes.conf.tolist(), strict=True, ) ] - if args.max_overlap >= 0: - kept = suppress_overlaps(predictions, max_iou=args.max_overlap) - suppressed += len(predictions) - len(kept) - predictions = [predictions[i] for i in kept] - label_file = label_path_for(image_path) truth = parse_label(label_file.read_text(encoding="utf-8")) if label_file.exists() else [] match = match_boxes(predictions, truth, args.iou_threshold) @@ -273,10 +241,6 @@ def main() -> int: "conf": args.conf, "iou_threshold": args.iou_threshold, "imgsz": args.imgsz, - "nms_iou": args.nms_iou, - "agnostic_nms": not args.no_agnostic_nms, - "max_overlap": args.max_overlap, - "suppressed_overlapping_boxes": suppressed, "outcomes": {split: dict(counter) for split, counter in outcomes.items()}, "per_class": { split: { @@ -293,8 +257,6 @@ def main() -> int: json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" ) - if suppressed: - logger.info("因与更高分的框重叠而丢弃: %d 个", suppressed) for split, counter in outcomes.items(): shown = sum(counter[k] for k in ("missed", "extra")) logger.info( diff --git a/tests/test_detection.py b/tests/test_detection.py index 1532354..4e1e4f1 100644 --- a/tests/test_detection.py +++ b/tests/test_detection.py @@ -10,12 +10,10 @@ from engine.detection import ( # noqa: E402 COLOR_UNMATCHED, Box, draw_boxes, - filter_overlapping_outputs, from_yolo, iou, match_boxes, parse_label, - suppress_overlaps, ) @@ -131,117 +129,3 @@ def test_draw_handles_boxes_at_the_border(): prediction = Box(0, 0.0, 0.0, 0.5, 0.05, 0.9) draw_boxes(image, [], [prediction], ["blackdot"], match_boxes([prediction], [])) assert COLOR_UNMATCHED in _colors(image) - - -# ---------------- 不重叠约束 ---------------- - - -def test_suppress_keeps_the_highest_score_of_an_overlapping_pair(): - """同一处缺陷被框两次时,留置信度高的那个。""" - strong = Box(0, 0.3, 0.3, 0.7, 0.7, 0.9) - weak = Box(1, 0.35, 0.35, 0.75, 0.75, 0.4) - assert suppress_overlaps([weak, strong]) == [1] # 下标 1 = strong - - -def test_suppress_is_cross_class(): - """NMS 只在同类内抑制;跨类重复要靠这一步。""" - a = Box(0, 0.3, 0.3, 0.7, 0.7, 0.9) - b = Box(5, 0.31, 0.31, 0.69, 0.69, 0.8) - assert suppress_overlaps([a, b]) == [0] - - -def test_suppress_keeps_disjoint_boxes(): - """真正分开的多个缺陷不能被误伤。""" - boxes = [ - Box(0, 0.0, 0.0, 0.2, 0.2, 0.9), - Box(0, 0.5, 0.5, 0.7, 0.7, 0.8), - Box(1, 0.8, 0.1, 0.95, 0.3, 0.7), - ] - assert suppress_overlaps(boxes) == [0, 1, 2] - - -def test_touching_edges_are_not_overlap(): - """边缘相接、面积不相交,IoU=0,应当都保留。""" - left = Box(0, 0.0, 0.0, 0.5, 1.0, 0.9) - right = Box(0, 0.5, 0.0, 1.0, 1.0, 0.8) - assert suppress_overlaps([left, right]) == [0, 1] - - -def test_contained_box_is_dropped_even_when_iou_is_below_the_threshold(): - """整图大框套一个小框:面积悬殊导致 IoU 很小,但显然是同一处目标的重复表达。 - - 这正是 `contained` 的用武之地——只在允许轻微重叠(max_iou>0)时才有区别, - 因为 max_iou=0 时任何包含都已经因 IoU>0 被抑制了。 - """ - big = Box(0, 0.0, 0.0, 1.0, 1.0, 0.9) - small = Box(1, 0.45, 0.45, 0.55, 0.55, 0.8) - assert iou(big, small) < 0.02 - - assert suppress_overlaps([big, small], max_iou=0.1) == [0] - assert suppress_overlaps([big, small], max_iou=0.1, contained=False) == [0, 1] - - -def test_max_iou_allows_slight_overlap(): - a = Box(0, 0.0, 0.0, 0.5, 0.5, 0.9) - b = Box(0, 0.4, 0.4, 0.9, 0.9, 0.8) # IoU 约 0.02 - assert suppress_overlaps([a, b]) == [0] - assert suppress_overlaps([a, b], max_iou=0.1) == [0, 1] - - -def test_suppress_handles_empty_and_single(): - assert suppress_overlaps([]) == [] - assert suppress_overlaps([Box(0, 0.1, 0.1, 0.2, 0.2, 0.5)]) == [0] - - -# ---------------- 验证器输出的过滤 ---------------- - - -def test_filter_overlapping_outputs_drops_the_lower_score(): - """ultralytics 验证器的输出是张量字典,筛选要对每个"每框一行"的张量同步生效。""" - torch = pytest.importorskip("torch") - - output = { - "bboxes": torch.tensor([[0.0, 0.0, 10.0, 10.0], [1.0, 1.0, 11.0, 11.0], - [50.0, 50.0, 60.0, 60.0]]), - "conf": torch.tensor([0.9, 0.5, 0.8]), - "cls": torch.tensor([0.0, 1.0, 2.0]), - "extra": torch.zeros((3, 0)), - } - filtered = filter_overlapping_outputs([output])[0] - - assert filtered["conf"].tolist() == pytest.approx([0.9, 0.8]) # 中间那个与第一个重叠 - assert filtered["cls"].tolist() == [0.0, 2.0] - assert filtered["bboxes"].shape == (2, 4) - assert filtered["extra"].shape == (2, 0) - - -def test_filter_keeps_the_object_untouched_when_nothing_overlaps(): - torch = pytest.importorskip("torch") - - output = { - "bboxes": torch.tensor([[0.0, 0.0, 1.0, 1.0], [5.0, 5.0, 6.0, 6.0]]), - "conf": torch.tensor([0.9, 0.8]), - "cls": torch.tensor([0.0, 0.0]), - } - filtered = filter_overlapping_outputs([output]) - assert filtered[0] is output # 没有变化时原样返回,不做无谓的拷贝 - - -def test_filter_handles_images_with_no_detection(): - torch = pytest.importorskip("torch") - - empty = {"bboxes": torch.zeros((0, 4)), "conf": torch.zeros(0), "cls": torch.zeros(0)} - assert filter_overlapping_outputs([empty])[0]["conf"].numel() == 0 - - -def test_filter_can_empty_an_image_entirely(): - """全部互相重叠时只留一个;这里构造两个几乎重合的框。""" - torch = pytest.importorskip("torch") - - output = { - "bboxes": torch.tensor([[0.0, 0.0, 10.0, 10.0], [0.1, 0.1, 10.1, 10.1]]), - "conf": torch.tensor([0.3, 0.9]), - "cls": torch.tensor([1.0, 1.0]), - } - filtered = filter_overlapping_outputs([output])[0] - assert filtered["conf"].tolist() == pytest.approx([0.9])