From 7747813a0387db9a38d083aee158727d23da8d06 Mon Sep 17 00:00:00 2001 From: Kevin Heye Date: Thu, 20 Aug 2026 16:47:01 +0200 Subject: [PATCH] Share detector postprocessing across nodes Every detector node does the same three things with a model's raw output: drop low-confidence predictions, suppress overlapping boxes, and turn what survives into the loop's dataclasses. None of it depends on the model, yet each node repository had grown its own copy, and the copies had drifted: clip_point was byte-identical in three places, while clip_box existed in the same three plus a fourth that used a centre-based convention under the same name. Move that code here as detector/postprocess.py and detector/geometry.py, and give the centre-based variant its own name (clip_box_centered) so the two conventions can no longer be confused. The library also carried two containers for the same detections: a detector reports ImageMetadata, a trainer's auto-detection pass reports Detections. to_image_metadata and to_detections now build both from one routine, so the two paths clip and filter identically instead of diverging. The scalar arguments of non_max_suppression and post_process are keyword-only: transposing origin_h and origin_w was too easy to do. Add tests/unit, the first suite that runs without a Learning Loop, and gate the credential-dependent jobs on it in CI. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/pytest.yml | 22 ++ AGENTS.md | 28 +- learning_loop_node/detector/geometry.py | 69 +++++ learning_loop_node/detector/postprocess.py | 264 ++++++++++++++++++ learning_loop_node/tests/unit/__init__.py | 0 learning_loop_node/tests/unit/pytest.ini | 10 + .../tests/unit/test_geometry.py | 35 +++ .../tests/unit/test_postprocess.py | 168 +++++++++++ run_tests.sh | 3 + 9 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 learning_loop_node/detector/geometry.py create mode 100644 learning_loop_node/detector/postprocess.py create mode 100644 learning_loop_node/tests/unit/__init__.py create mode 100644 learning_loop_node/tests/unit/pytest.ini create mode 100644 learning_loop_node/tests/unit/test_geometry.py create mode 100644 learning_loop_node/tests/unit/test_postprocess.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 3e456ca2..e334d0b4 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -3,7 +3,28 @@ name: Run Tests on: [push] jobs: + unit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: set up uv + uses: astral-sh/setup-uv@v3 + - name: install dependencies + run: | + uv sync --extra dev --frozen + - name: test_unit + # no Learning Loop and no secrets needed, so this suite gates the slow ones + run: | + uv run python -m pytest learning_loop_node/tests/unit -v + pytest_3_10: + needs: + - unit runs-on: ubuntu-latest timeout-minutes: 30 strategy: @@ -91,6 +112,7 @@ jobs: slack: needs: + - unit - pytest_3_10 - pytest_3_13 if: always() # also execute when pytest fails diff --git a/AGENTS.md b/AGENTS.md index 823eeee6..cf26c812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,9 @@ environment variables, the node types and how to write a node against them. machinery, `trainer/`, `detector/`, `annotation/` for the per-type base logic, `data_classes/` and `enums/` for the wire types, `loop_communication.py` and `data_exchanger.py` for the loop-facing HTTP and socket.io traffic. -- `learning_loop_node/tests/` — `annotator`, `detector`, `trainer` and `general` suites. +- `learning_loop_node/tests/` — `unit`, `annotator`, `detector`, `trainer` and `general` suites. + Only `unit` runs without a Learning Loop; it covers the pure helpers such as + `detector/postprocess.py` and `detector/geometry.py`. - `mock_trainer/`, `mock_detector/`, `mock_annotator/` — reference implementations with their own tests. They are what `loop`'s CI runs against, so they are also the best template for a new node. - `demo_segmentation_tool/` — a worked annotator example. @@ -52,15 +54,30 @@ on 401/429) for the REST API, and a socket.io *client* for status updates and lo - **Annotator** — thin: it forwards the loop frontend's `handle_user_input` events into `AnnotatorLogic` and keeps a per-frontend history. +`detector/postprocess.py` and `detector/geometry.py` hold the parts of a detector that do *not* +depend on the model: confidence filtering, per-class NMS, box/point clipping, and turning +predictions into the loop's dataclasses. A node should import them rather than write its own — +every node repository had grown its own drifting copy, which is why they live here. Note the two +containers: `to_image_metadata` builds what a **detector** node reports, `to_detections` what a +**trainer**'s auto-detection pass reports. Both go through one routine, so the two paths cannot +drift apart again. + All node state lives under `GLOBALS.data_folder` (`DATA_FOLDER`, default `/data`): `uuids.json` (the node uuid is derived from its name and reused across restarts), `models/` plus the `current_model` symlink, `outbox/`, and the per-project training folders. ## Running and testing -The suites talk to a real Learning Loop instance and read their credentials from a local `.env` -(`LOOP_HOST`, `LOOP_USERNAME`, `LOOP_PASSWORD`). Without a reachable loop they cannot pass — do not -treat their failure as a regression you introduced. +The `unit` suite is self-contained — no Learning Loop, no credentials, no network — so it is the +one to run while iterating, and the one CI gates the others on: + +```bash +python -m pytest learning_loop_node/tests/unit -v +``` + +Every other suite talks to a real Learning Loop instance and reads its credentials from a local +`.env` (`LOOP_HOST`, `LOOP_USERNAME`, `LOOP_PASSWORD`). Without a reachable loop those cannot pass — +do not treat their failure as a regression you introduced. ```bash ./run_tests.sh # all suites @@ -91,7 +108,8 @@ uvx ruff check . A clean tree already reports several hundred ruff findings, so a clean run is not a reachable goal. Compare the count on the files you touched, before and after. -`.github/workflows/pytest.yml` runs the suites, `publish.yml` releases to PyPI on a tagged release. +`.github/workflows/pytest.yml` runs the suites (the `unit` job first, without secrets), +`publish.yml` releases to PyPI on a tagged release. ## Working in this repository diff --git a/learning_loop_node/detector/geometry.py b/learning_loop_node/detector/geometry.py new file mode 100644 index 00000000..b17ed33b --- /dev/null +++ b/learning_loop_node/detector/geometry.py @@ -0,0 +1,69 @@ +"""Box and point clipping shared by every detector node. + +The loop stores a box as its top-left corner plus a size, so :func:`clip_box` is the form a +node needs when it hands detections to the loop. Model outputs are not always in that form — +:func:`clip_box_centered` keeps the centre-based convention explicit instead of letting two +incompatible functions share one name, which is how the same helper ended up meaning two +different things in different node repositories. +""" + + +def clip_box( + *, + x1: float, + y1: float, + width: float, + height: float, + img_width: int, + img_height: int, +) -> tuple[int, int, int, int]: + """Clip a top-left-anchored box to the image bounds. + + :param x1: Left edge of the box. + :param y1: Top edge of the box. + :return: The clipped ``(x1, y1, width, height)`` as ints; the size is never negative. + """ + x2 = x1 + width + y2 = y1 + height + + clipped_x1 = round(max(0.0, x1)) + clipped_y1 = round(max(0.0, y1)) + clipped_x2 = round(min(float(img_width), x2)) + clipped_y2 = round(min(float(img_height), y2)) + + clipped_width = max(clipped_x2 - clipped_x1, 0) + clipped_height = max(clipped_y2 - clipped_y1, 0) + + return clipped_x1, clipped_y1, clipped_width, clipped_height + + +def clip_box_centered( + *, + x: float, + y: float, + width: float, + height: float, + img_width: int, + img_height: int, +) -> tuple[float, float, float, float]: + """Clip a centre-anchored box to the image bounds, keeping it centre-anchored. + + Clipping moves the centre, because only the part of the box inside the image survives. + + :param x: Horizontal centre of the box. + :param y: Vertical centre of the box. + :return: The clipped ``(x, y, width, height)``, still centre-anchored. + """ + left = max(0.0, x - 0.5 * width) + top = max(0.0, y - 0.5 * height) + right = min(float(img_width), x + 0.5 * width) + bottom = min(float(img_height), y + 0.5 * height) + + return 0.5 * (left + right), 0.5 * (top + bottom), right - left, bottom - top + + +def clip_point(x: float, y: float, img_width: int, img_height: int) -> tuple[float, float]: + """Clamp a point into the image bounds.""" + x = min(max(0, x), img_width) + y = min(max(0, y), img_height) + return x, y diff --git a/learning_loop_node/detector/postprocess.py b/learning_loop_node/detector/postprocess.py new file mode 100644 index 00000000..ae47fbcc --- /dev/null +++ b/learning_loop_node/detector/postprocess.py @@ -0,0 +1,264 @@ +"""Model-agnostic detection postprocessing. + +Every detection node ends up doing the same three things with a model's raw output: drop +low-confidence predictions, suppress overlapping boxes, and turn what survives into the +loop's detection dataclasses. None of that depends on the model, so it lives here rather +than being re-derived — and re-diverging — in each node repository. + +Two containers carry the same detections in this library: a detector node reports +:class:`~learning_loop_node.data_classes.image_metadata.ImageMetadata`, while a trainer's +auto-detection pass reports :class:`~learning_loop_node.data_classes.detections.Detections`. +:func:`to_image_metadata` and :func:`to_detections` build them from the same routine, so both +paths clip and filter identically. +""" + +import logging +from collections import namedtuple + +import numpy as np + +from ..data_classes import ( + BoxDetection, + Category, + Detections, + ImageMetadata, + ModelInformation, + PointDetection, +) +from ..enums import CategoryType +from .geometry import clip_box, clip_point + +MIN_BOX_SIZE: int = 2 +"""Boxes this small are dropped: they carry no usable information and clutter the loop.""" + +Detection = namedtuple('Detection', 'x y w h category probability') +"""One surviving prediction. ``x``/``y`` are the top-left corner, ``category`` is an index +into :attr:`ModelInformation.categories`.""" + + +def category_by_index(model_information: ModelInformation, index: int) -> Category: + """Resolve the category a model's class index refers to. + + Models emit class indices, and the order of ``model_information.categories`` is what + gives them meaning — so an out-of-range index is a model/metadata mismatch, not a + detection to skip quietly. + + :raises ValueError: If the index is outside the model's category list. + """ + categories = model_information.categories + if not 0 <= index < len(categories): + raise ValueError( + f'category index {index} is out of range for a model with {len(categories)} categories') + return categories[index] + + +def category_by_name(model_information: ModelInformation, name: str) -> Category: + """Resolve a category by name, for models whose outputs are named rather than indexed. + + :raises ValueError: If no category of that name exists. + """ + for category in model_information.categories: + if category.name == name: + return category + known = ', '.join(category.name for category in model_information.categories) + raise ValueError(f'unknown category name {name!r}; the model knows: {known}') + + +def bbox_iou( + box1: np.ndarray, + box2: np.ndarray, +) -> np.ndarray: + """Compute IoU between box1 (1x4) and box2 (Nx4), both in x1y1x2y2 format.""" + b1_x1, b1_y1, b1_x2, b1_y2 = box1[:, 0], box1[:, 1], box1[:, 2], box1[:, 3] + b2_x1, b2_y1, b2_x2, b2_y2 = box2[:, 0], box2[:, 1], box2[:, 2], box2[:, 3] + + inter_x1 = np.maximum(b1_x1, b2_x1) + inter_y1 = np.maximum(b1_y1, b2_y1) + inter_x2 = np.minimum(b1_x2, b2_x2) + inter_y2 = np.minimum(b1_y2, b2_y2) + + inter_area = np.clip(inter_x2 - inter_x1 + 1, 0, None) * np.clip(inter_y2 - inter_y1 + 1, 0, None) + b1_area = (b1_x2 - b1_x1 + 1) * (b1_y2 - b1_y1 + 1) + b2_area = (b2_x2 - b2_x1 + 1) * (b2_y2 - b2_y1 + 1) + + return inter_area / (b1_area + b2_area - inter_area + 1e-16) + + +def non_max_suppression( + boxes: np.ndarray, + scores: np.ndarray, + classes: np.ndarray, + *, + iou_threshold: float, + origin_h: int, + origin_w: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Clip to image bounds, sort by score descending, apply per-class NMS. + + :return: ``(boxes, scores, classes)`` — filtered arrays in the same order. + """ + boxes = boxes.copy() + boxes[:, 0] = np.clip(boxes[:, 0], 0, origin_w - 1) + boxes[:, 2] = np.clip(boxes[:, 2], 0, origin_w - 1) + boxes[:, 1] = np.clip(boxes[:, 1], 0, origin_h - 1) + boxes[:, 3] = np.clip(boxes[:, 3], 0, origin_h - 1) + + order = np.argsort(-scores) + boxes = boxes[order] + scores = scores[order] + classes = classes[order] + + keep_indices: list[int] = [] + for cls in np.unique(classes): + cls_mask = classes == cls + cls_indices = np.where(cls_mask)[0] + cls_boxes = boxes[cls_mask] + + while len(cls_indices) > 0: + keep_indices.append(cls_indices[0]) + if len(cls_indices) == 1: + break + ious = bbox_iou(cls_boxes[0:1], cls_boxes[1:]) + keep_mask = ious.flatten() <= iou_threshold + cls_indices = cls_indices[1:][keep_mask] + cls_boxes = cls_boxes[1:][keep_mask] + + keep_indices = sorted(keep_indices) + return boxes[keep_indices], scores[keep_indices], classes[keep_indices] + + +def post_process( + boxes: np.ndarray, + scores: np.ndarray, + classes: np.ndarray, + *, + conf_threshold: float, + iou_threshold: float, + origin_h: int, + origin_w: int, +) -> list[Detection]: + """Filter by confidence, run NMS, return a :class:`Detection` list in x/y/w/h form.""" + mask = scores > conf_threshold + boxes = boxes[mask].copy() + scores = scores[mask] + classes = classes[mask] + + if len(scores) == 0: + return [] + + boxes, scores, classes = non_max_suppression( + boxes, scores, classes, + iou_threshold=iou_threshold, origin_h=origin_h, origin_w=origin_w) + + result = [] + for j, box in enumerate(boxes): + x1, y1, x2, y2 = box + w = x2 - x1 + h = y2 - y1 + result.append(Detection(int(x1), int(y1), int(w), int(h), + int(classes[j]), round(float(scores[j]), 2))) + return result + + +def detections_from_xyxy( + *, + labels: list[float], + boxes: list[list[float]], + scores: list[float], +) -> list[Detection]: + """Convert already-suppressed model output into :class:`Detection` values. + + For nodes whose model (or a torch/ONNX op) has done the suppression already, so only the + coordinate conversion is left. Corners are rounded rather than truncated, which is half a + pixel more faithful than :func:`post_process` — that one keeps truncating so its output + stays bit-identical to what detectors reported before this module existed. + """ + result = [] + for label, box, score in zip(labels, boxes, scores, strict=True): + x1, y1, x2, y2 = (round(value) for value in box) + result.append(Detection(x1, y1, x2 - x1, y2 - y1, int(label), score)) + return result + + +def _append_detections( + target: ImageMetadata | Detections, + detections: list[Detection], + model_information: ModelInformation, + im_height: int, + im_width: int, +) -> None: + """Resolve each detection's category and append it to ``target``, clipped to the image.""" + skipped_detections = [] + + for detection in detections: + x, y, w, h, category_idx, probability = detection + category = category_by_index(model_information, category_idx) + if w <= MIN_BOX_SIZE or h <= MIN_BOX_SIZE: + skipped_detections.append((category.name, detection)) + continue + if category.type == CategoryType.Box: + clipped_x1, clipped_y1, clipped_w, clipped_h = clip_box( + x1=x, + y1=y, + width=w, + height=h, + img_width=im_width, + img_height=im_height, + ) + target.box_detections.append( + BoxDetection( + category_name=category.name, + x=clipped_x1, + y=clipped_y1, + width=clipped_w, + height=clipped_h, + category_id=category.id, + model_name=model_information.version, + confidence=probability, + ) + ) + elif category.type == CategoryType.Point: + cx, cy = x + w / 2, y + h / 2 + cx, cy = clip_point(cx, cy, im_width, im_height) + target.point_detections.append( + PointDetection( + category_name=category.name, + x=cx, + y=cy, + category_id=category.id, + model_name=model_information.version, + confidence=probability, + ) + ) + else: + logging.warning('Unsupported category type %s for category %s', category.type, category.name) + + if skipped_detections: + log_msg = '\n'.join([str(d) for d in skipped_detections]) + logging.warning('Removed %d small detections from result: \n%s', len(skipped_detections), log_msg) + + +def to_image_metadata( + detections: list[Detection], + model_information: ModelInformation, + im_height: int, + im_width: int, +) -> ImageMetadata: + """Build the container a *detector* node reports from a list of detections.""" + image_metadata = ImageMetadata() + _append_detections(image_metadata, detections, model_information, im_height, im_width) + return image_metadata + + +def to_detections( + detections: list[Detection], + model_information: ModelInformation, + im_height: int, + im_width: int, + *, + image_id: str | None = None, +) -> Detections: + """Build the container a *trainer*'s auto-detection pass reports.""" + result = Detections(image_id=image_id) + _append_detections(result, detections, model_information, im_height, im_width) + return result diff --git a/learning_loop_node/tests/unit/__init__.py b/learning_loop_node/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/learning_loop_node/tests/unit/pytest.ini b/learning_loop_node/tests/unit/pytest.ini new file mode 100644 index 00000000..2207d1ac --- /dev/null +++ b/learning_loop_node/tests/unit/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +python_files = test_*.py +asyncio_mode = auto + +cache_dir = /tmp/pytest_cache + +# for debbuging tests: +; log_cli_level = INFO +; log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) +; log_cli_date_format=%Y-%m-%d %H:%M:%S \ No newline at end of file diff --git a/learning_loop_node/tests/unit/test_geometry.py b/learning_loop_node/tests/unit/test_geometry.py new file mode 100644 index 00000000..f056f79d --- /dev/null +++ b/learning_loop_node/tests/unit/test_geometry.py @@ -0,0 +1,35 @@ +from ...detector.geometry import clip_box, clip_box_centered, clip_point + + +def test_box_inside_the_image_is_unchanged(): + assert clip_box(x1=10, y1=20, width=30, height=40, img_width=100, img_height=100) == (10, 20, 30, 40) + + +def test_box_is_clipped_to_the_image_bounds(): + assert clip_box(x1=-20, y1=-20, width=60, height=60, img_width=100, img_height=100) == (0, 0, 40, 40) + assert clip_box(x1=80, y1=80, width=40, height=40, img_width=100, img_height=100) == (80, 80, 20, 20) + + +def test_box_fully_outside_the_image_collapses_to_zero_size(): + # The corner is only clamped at the lower bound, so it stays at 200 — the zero size is + # what marks the box as empty. Detector output cannot reach here, because + # non_max_suppression already clips every box into the image. + assert clip_box(x1=200, y1=200, width=10, height=10, img_width=100, img_height=100) == (200, 200, 0, 0) + + +def test_box_corners_are_rounded(): + assert clip_box(x1=10.4, y1=10.6, width=20.0, height=20.0, img_width=100, img_height=100) == (10, 11, 20, 20) + + +def test_centered_box_keeps_its_centre_when_it_fits(): + assert clip_box_centered(x=50, y=50, width=20, height=20, img_width=100, img_height=100) == (50, 50, 20, 20) + + +def test_clipping_a_centered_box_moves_its_centre(): + # only the right half of the box is inside the image, so the centre moves right + assert clip_box_centered(x=0, y=50, width=20, height=20, img_width=100, img_height=100) == (5, 50, 10, 20) + + +def test_point_is_clamped_into_the_image(): + assert clip_point(50, 50, 100, 100) == (50, 50) + assert clip_point(-10, 150, 100, 100) == (0, 100) diff --git a/learning_loop_node/tests/unit/test_postprocess.py b/learning_loop_node/tests/unit/test_postprocess.py new file mode 100644 index 00000000..14dbcd6a --- /dev/null +++ b/learning_loop_node/tests/unit/test_postprocess.py @@ -0,0 +1,168 @@ +import numpy as np +import pytest + +from ...data_classes import Category, ModelInformation +from ...detector.postprocess import ( + Detection, + bbox_iou, + category_by_index, + category_by_name, + detections_from_xyxy, + non_max_suppression, + post_process, + to_detections, + to_image_metadata, +) +from ...enums import CategoryType + +BOX = Category(id='uuid-box', name='car', type=CategoryType.Box) +POINT = Category(id='uuid-point', name='weed', type=CategoryType.Point) + + +def model_information(*categories: Category) -> ModelInformation: + return ModelInformation(id='model-uuid', host='localhost', organization='zauberzeug', + project='pytest', version='1.2', categories=list(categories or (BOX, POINT))) + + +# ---------------------------------------------------------------- category resolution + +def test_category_is_resolved_by_index(): + assert category_by_index(model_information(), 1) is POINT + + +@pytest.mark.parametrize('index', [-1, 2, 99]) +def test_an_index_outside_the_model_categories_is_an_error(index: int): + # a mismatch between model and metadata must not be silently skipped + with pytest.raises(ValueError, match='out of range'): + category_by_index(model_information(), index) + + +def test_category_is_resolved_by_name(): + assert category_by_name(model_information(), 'weed') is POINT + + +def test_an_unknown_category_name_lists_the_known_ones(): + with pytest.raises(ValueError, match='car, weed'): + category_by_name(model_information(), 'tractor') + + +# ---------------------------------------------------------------- iou and suppression + +def test_identical_boxes_have_an_iou_of_one(): + box = np.array([[0, 0, 10, 10]], dtype=np.float32) + assert bbox_iou(box, box)[0] == pytest.approx(1.0) + + +def test_disjoint_boxes_have_an_iou_of_zero(): + a = np.array([[0, 0, 10, 10]], dtype=np.float32) + b = np.array([[100, 100, 110, 110]], dtype=np.float32) + assert bbox_iou(a, b)[0] == pytest.approx(0.0) + + +def test_overlapping_boxes_of_one_class_are_suppressed_keeping_the_best(): + boxes = np.array([[10, 10, 60, 60], [12, 12, 62, 62]], dtype=np.float32) + scores = np.array([0.9, 0.8], dtype=np.float32) + kept_boxes, kept_scores, _ = non_max_suppression( + boxes, scores, np.array([0, 0]), iou_threshold=0.45, origin_h=200, origin_w=200) + assert len(kept_boxes) == 1 + assert kept_scores[0] == pytest.approx(0.9) + + +def test_overlapping_boxes_of_different_classes_both_survive(): + boxes = np.array([[10, 10, 60, 60], [12, 12, 62, 62]], dtype=np.float32) + kept_boxes, _, _ = non_max_suppression( + boxes, np.array([0.9, 0.8], dtype=np.float32), np.array([0, 1]), + iou_threshold=0.45, origin_h=200, origin_w=200) + assert len(kept_boxes) == 2 + + +def test_suppression_clips_boxes_into_the_image(): + boxes = np.array([[-10, -10, 300, 300]], dtype=np.float32) + kept_boxes, _, _ = non_max_suppression( + boxes, np.array([0.9], dtype=np.float32), np.array([0]), + iou_threshold=0.45, origin_h=100, origin_w=100) + assert list(kept_boxes[0]) == [0, 0, 99, 99] + + +# ---------------------------------------------------------------- post_process + +def test_post_process_drops_predictions_below_the_confidence_threshold(): + boxes = np.array([[10, 10, 60, 60], [100, 100, 150, 150]], dtype=np.float32) + result = post_process(boxes, np.array([0.9, 0.1], dtype=np.float32), np.array([0, 0]), + conf_threshold=0.5, iou_threshold=0.45, origin_h=200, origin_w=200) + assert result == [Detection(10, 10, 50, 50, 0, 0.9)] + + +def test_post_process_on_an_empty_prediction_returns_nothing(): + empty_boxes = np.zeros((0, 4), dtype=np.float32) + assert post_process(empty_boxes, np.zeros(0, dtype=np.float32), np.zeros(0, dtype=int), + conf_threshold=0.5, iou_threshold=0.45, origin_h=10, origin_w=10) == [] + + +def test_already_suppressed_output_is_converted_with_rounded_corners(): + assert detections_from_xyxy(labels=[1.0], boxes=[[10.4, 10.6, 60.4, 60.6]], scores=[0.55]) == \ + [Detection(10, 11, 50, 50, 1, 0.55)] + + +def test_converting_already_suppressed_output_requires_matching_lengths(): + with pytest.raises(ValueError): + detections_from_xyxy(labels=[1.0, 2.0], boxes=[[0.0, 0.0, 1.0, 1.0]], scores=[0.5]) + + +# ---------------------------------------------------------------- building the containers + +def test_a_box_category_becomes_a_box_detection(): + metadata = to_image_metadata([Detection(10, 20, 30, 40, 0, 0.9)], model_information(), 200, 200) + assert len(metadata.point_detections) == 0 + detection = metadata.box_detections[0] + assert (detection.x, detection.y, detection.width, detection.height) == (10, 20, 30, 40) + assert (detection.category_name, detection.category_id) == ('car', 'uuid-box') + assert detection.model_name == '1.2' + assert detection.confidence == pytest.approx(0.9) + + +def test_a_point_category_becomes_the_centre_of_the_box(): + metadata = to_image_metadata([Detection(100, 100, 40, 40, 1, 0.7)], model_information(), 200, 200) + assert len(metadata.box_detections) == 0 + detection = metadata.point_detections[0] + assert (detection.x, detection.y) == (120, 120) + assert detection.category_id == 'uuid-point' + + +def test_detections_are_clipped_to_the_image(): + metadata = to_image_metadata([Detection(-20, -20, 60, 60, 0, 0.5)], model_information(), 200, 200) + detection = metadata.box_detections[0] + assert (detection.x, detection.y, detection.width, detection.height) == (0, 0, 40, 40) + + +@pytest.mark.parametrize('width,height', [(2, 30), (30, 2), (1, 1)]) +def test_boxes_too_small_to_be_useful_are_dropped(width: int, height: int): + metadata = to_image_metadata([Detection(5, 5, width, height, 0, 0.5)], model_information(), 200, 200) + assert len(metadata) == 0 + + +def test_a_category_type_the_node_cannot_report_is_skipped(): + classification = Category(id='uuid-cls', name='ripe', type=CategoryType.Classification) + metadata = to_image_metadata([Detection(10, 10, 30, 30, 0, 0.5)], + model_information(classification), 200, 200) + assert len(metadata) == 0 + + +def test_the_trainer_container_carries_the_image_id(): + result = to_detections([Detection(10, 20, 30, 40, 0, 0.9)], model_information(), 200, 200, + image_id='image-uuid') + assert result.image_id == 'image-uuid' + assert len(result.box_detections) == 1 + + +def test_trainer_and_detector_paths_agree_on_the_same_detections(): + """The whole point of sharing this code: auto-detections and live detections must match.""" + detections = [Detection(-5, -5, 60, 60, 0, 0.9), Detection(100, 100, 40, 40, 1, 0.7), + Detection(5, 5, 1, 1, 0, 0.5)] + metadata = to_image_metadata(detections, model_information(), 200, 200) + result = to_detections(detections, model_information(), 200, 200, image_id='image-uuid') + + assert [(d.x, d.y, d.width, d.height, d.category_id) for d in result.box_detections] == \ + [(d.x, d.y, d.width, d.height, d.category_id) for d in metadata.box_detections] + assert [(d.x, d.y, d.category_id) for d in result.point_detections] == \ + [(d.x, d.y, d.category_id) for d in metadata.point_detections] diff --git a/run_tests.sh b/run_tests.sh index e83d3852..e8ae7b66 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -6,6 +6,7 @@ set -o allexport; source .env; set +o allexport # Check if argument is provided if [ $# -eq 1 ]; then # Run tests with filter + python -m pytest learning_loop_node/tests/unit -v -s -k "$1" python -m pytest learning_loop_node/tests/annotator -v -s -k "$1" python -m pytest learning_loop_node/tests/detector -v -s -k "$1" python -m pytest learning_loop_node/tests/trainer -v -s -k "$1" @@ -17,6 +18,8 @@ fi # Run the tests +# unit runs first: it is the only suite that needs no Learning Loop +python -m pytest learning_loop_node/tests/unit -v python -m pytest learning_loop_node/tests/annotator -v python -m pytest learning_loop_node/tests/detector -v python -m pytest learning_loop_node/tests/trainer -v