Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -91,6 +112,7 @@ jobs:

slack:
needs:
- unit
- pytest_3_10
- pytest_3_13
if: always() # also execute when pytest fails
Expand Down
28 changes: 23 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
69 changes: 69 additions & 0 deletions learning_loop_node/detector/geometry.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading