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