From cc5198eb0678424e412c15e5d1baf15b9d0240e2 Mon Sep 17 00:00:00 2001 From: andrewwhitecdw Date: Mon, 17 Aug 2026 19:17:36 -0500 Subject: [PATCH] fix(detectnet): truncate clustered boxes to MAX_BOXES to avoid broadcast crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `cluster()` in the DetectNet Python layers (`ClusterGroundtruth` / `ClusterDetections`) converts grid-format coverage/bbox network output into a fixed-size `[batch_size, MAX_BOXES, 5]` blob. When an image produces more than `MAX_BOXES` (50) box proposals, the forward pass dies with: ``` ValueError: could not broadcast input array from shape (256,4) into shape (50,4) ``` so any test/val image with >50 ground-truth objects (or >50 clustered detections) aborts the whole test phase instead of just keeping the 50 slots the blob has room for. ## Root cause ```python boxes = np.zeros([batch_size, MAX_BOXES, 5]) ... [r, c] = boxes_cur_image.shape boxes[i, 0:r, 0:c] = boxes_cur_image ``` `boxes[i]` only has `MAX_BOXES` rows, but `r` (the number of proposals that survived thresholding / dedup / groupRectangles voting) is unbounded, so the slice assignment fails to broadcast whenever `r > MAX_BOXES`. Repro: 16x16 grid, stride 1, all 256 cells covered => 256 ground-truth proposals => `ValueError: could not broadcast input array from shape (256,4) into shape (50,4)`. ## Fix ```python r = min(r, MAX_BOXES) boxes[i, 0:r, 0:c] = boxes_cur_image[0:r] ``` Clip the copy to the blob capacity, which is exactly what the "max_bbox_per_image = MAX_BOXES" contract documented in the layer docstrings implies. ## Testing No GPU/caffe build needed: loaded `clustering.py` standalone with `caffe` and `cv2` stubbed out and drove `cluster()` with a synthetic batch of 256 covered grid cells (`uv run --with numpy python repro.py`): - pre-fix (stash): `ValueError: could not broadcast input array from shape (256,4) into shape (50,4)` — crash reproduced. - post-fix: passes, output shape `(1, 50, 5)` with all 50 slots populated. ⚠️ The full pycaffe test suite requires a compiled caffe with GPU support and could not be run locally; please rely on CI for end-to-end validation. ## Why existing tests missed it `python/caffe/test/` has no DetectNet clustering coverage, and every shipped DetectNet example happens to produce fewer than 50 boxes per image. Signed-off-by: Andrew White Signed-off-by: andrewwhitecdw --- python/caffe/layers/detectnet/clustering.py | 3 +- .../test/test_detectnet_cluster_max_boxes.py | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 python/caffe/test/test_detectnet_cluster_max_boxes.py diff --git a/python/caffe/layers/detectnet/clustering.py b/python/caffe/layers/detectnet/clustering.py index 9547db7d880..0062e703563 100644 --- a/python/caffe/layers/detectnet/clustering.py +++ b/python/caffe/layers/detectnet/clustering.py @@ -228,6 +228,7 @@ def cluster(self, net_cvg, net_boxes): if (boxes_cur_image.shape[0] != 0): [r, c] = boxes_cur_image.shape - boxes[i, 0:r, 0:c] = boxes_cur_image + r = min(r, MAX_BOXES) + boxes[i, 0:r, 0:c] = boxes_cur_image[0:r] return boxes diff --git a/python/caffe/test/test_detectnet_cluster_max_boxes.py b/python/caffe/test/test_detectnet_cluster_max_boxes.py new file mode 100644 index 00000000000..6dc070e3598 --- /dev/null +++ b/python/caffe/test/test_detectnet_cluster_max_boxes.py @@ -0,0 +1,65 @@ +import sys +import types +from pathlib import Path + +import numpy as np +import pytest + + +def _stub_caffe_and_cv2(): + """Provide minimal stubs so clustering.py can be imported without pycaffe.""" + caffe_pkg = types.ModuleType("caffe") + caffe_pkg.__path__ = [] + caffe_pkg.Layer = object + sys.modules["caffe"] = caffe_pkg + + caffe_layers = types.ModuleType("caffe.layers") + caffe_layers.__path__ = [] + sys.modules["caffe.layers"] = caffe_layers + + detectnet_dir = str(Path(__file__).resolve().parent.parent / "layers" / "detectnet") + caffe_detectnet = types.ModuleType("caffe.layers.detectnet") + caffe_detectnet.__path__ = [detectnet_dir] + sys.modules["caffe.layers.detectnet"] = caffe_detectnet + + cv2_stub = types.ModuleType("cv2") + cv2_stub.groupRectangles = lambda boxes, *args, **kwargs: ([], []) + sys.modules["cv2"] = cv2_stub + + +_stub_caffe_and_cv2() +from caffe.layers.detectnet.clustering import ( # noqa: E402 + MAX_BOXES, + cluster, +) + + +class _FakeGroundTruthLayer: + is_groundtruth = True + image_size_x = 16 + image_size_y = 16 + stride = 1 + coverage_threshold = 0.0 + + +def test_cluster_truncates_boxes_to_max_boxes(): + """cluster() must not crash when an image produces more than MAX_BOXES proposals. + + Regression: the output blob is fixed at [batch_size, MAX_BOXES, 5], but the + number of proposals was unbounded, so assigning more than MAX_BOXES rows + raised ValueError: could not broadcast input array from shape (256,4) + into shape (50,4). + """ + layer = _FakeGroundTruthLayer() + + # 16x16 grid, stride 1, every cell covered => 256 ground-truth proposals. + net_cvg = np.ones((1, 1, 16, 16), dtype=np.float32) + net_boxes = np.zeros((1, 4, 16, 16), dtype=np.float32) + net_boxes[0, 2, :, :] = 1.0 # width + net_boxes[0, 3, :, :] = 1.0 # height + + result = cluster(layer, net_cvg, net_boxes) + + assert result.shape == (1, MAX_BOXES, 5) + # All MAX_BOXES slots should be populated (no all-zero padding from a crash). + assert np.count_nonzero(result[0, :, :]) > 0