Skip to content

Share the generic parts of a node - #94

Merged
klangenk merged 33 commits into
mainfrom
share-generic-node-code
Sep 10, 2026
Merged

klangenk merged 33 commits into
mainfrom
share-generic-node-code

Conversation

@klangenk

@klangenk klangenk commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Three node repositories build on this library, and a surprising amount of what they contain is not about their models at all. It had been copied between them and had drifted:

  • clip_point was byte-identical in five places. clip_box existed in those five plus a sixth that used a centre-based convention under the same name, so the function meant two different things depending on the file.
  • Confidence filtering, per-class NMS, and building the loop's detection dataclasses existed three to four times over.
  • Every main.py repeated the same twenty lines of parser-and-uvicorn scaffolding — eight copies.
  • The confusion-matrix F1 score was implemented three times, over two different data shapes.
  • Batch-size selection was implemented three times, three different ways: a real probe, an estimate parsed out of torchinfo's summary text, and a doubling loop catching bare RuntimeError.
  • _get_executor_error_from_log was byte-identical in two repositories.

A fourth node would have copied all of it again. This adds the shared home for it, in three commits that are worth reading in order.

What is added

detector/postprocess.py, detector/geometry.py, detector/categories.py — Detection, bbox_iou, non_max_suppression, post_process, detections_from_xyxy; one clipping implementation per convention (clip_box, clip_box_centered, clip_point); and category_by_index/category_by_name, which resolve a model's metadata rather than post-processing anything and so live apart from it.

The two containers for the same detections are unified: to_image_metadata (what a detector reports) and to_detections (what a trainer's auto-detection pass reports) share one routine, so the two paths can no longer clip and filter differently — which they did.

helpers/entrypoint.py — node_parser and run_node. A setting is named after its flag: --conf-threshold reads CONF_THRESHOLD, and --help lists it. legacy_env_prefix lets a node that once required a prefix keep reading the old names with a deprecation warning.

--host/--port are the deliberate exception, reading NODE_HOST/NODE_PORT. The bare HOST already means the loop's address — every deployment sets it — so a node deriving it would hand that to uvicorn and fail to bind. There is a test for exactly this.

trainer/cuda.py — usable_memory_bytes, limit_cuda_memory and free_cuda_memory, the two halves of a --vram-limit-gb setting: the budget a batch-size probe measures against, and the cap that holds the process to it. This is the one module that imports torch, and the package deliberately does not declare it — only a trainer imports it, and a trainer brings torch already. Its test installs a stand-in under that name, which covers the arithmetic and the guards; that the cap holds can only be seen on a card.

trainer/subprocess.py, trainer/batch_size.py, trainer/metrics.py — iterator_cpu_bound, the power-of-two batch-size search, and macro_f1. The batch-size search imports no framework: it is pure arithmetic around a fits predicate the trainer supplies, which is what lets any node use it. is_out_of_memory is part of that: allocation failures do not all arrive as a dedicated error type — cuDNN and cuBLAS raise plain RuntimeError — and one repository was catching bare RuntimeError, so every crash looked like a full card.

TrainerLogic._get_executor_error_from_log gains a default implementation and stops being abstract; existing overrides keep working.

Testing

Adds learning_loop_node/tests/unit/, the first suite that runs without a Learning Loop — no credentials, no network, 214 tests in about 1.5 seconds, on every Python version requires-python promises. CI gates the credential-dependent jobs on it.

Those jobs are restructured while here. They used to split the suites between versions — general, detector and mock_detector on 3.10, annotator, trainer and mock_trainer on 3.13 — so neither version was tested, only half of each. One matrix job now runs all six on 3.10 and 3.13, serialised: they share one Learning Loop, and test_general creates and deletes zauberzeug/pytest_nodelib_general by a fixed name.

That gap is why the node repositories have so few tests: learning_loop_node/tests/ is excluded from the wheel, so nothing in it is importable downstream. Making the helpers shippable is a follow-up phase; this establishes the suite.

A disagreeing environment variable no longer points at production

read_from_env takes an ordered list of names — ['LOOP_HOST', 'HOST'] and so on — and returned None when two were set to different values. The caller then fell back to its default, and host()'s default is 'learning-loop.ai'. So a .env carrying both spellings with different values pointed the node at production, with only a log warning.

Since values is built in possible_names order and then filtered, the first surviving value is already the preferred one — so the fix is to warn and fall through to the existing return values[0]. ignore_errors=False still raises.

This is the right layer for it: the node repositories' docker.sh scripts had been working around it by forwarding exactly one spelling, which forced every sub-project to agree on which. Now that the container receives the whole .env, only the library can decide. Six tests added in tests/unit/test_environment_reader.py.

Notes for the reviewer

  • Box corners are rounded once, in clip_box. The first version of this branch kept post_process's int() so detector output stayed bit-identical; review pointed out that truncating and then rounding again is strictly worse than rounding once. A box moves by at most a pixel, confidences are no longer clipped to two decimals, and point detections gain real precision — their centre used to come from truncated ints. No node consumes this yet.
  • The PEP 695 type parameters (def f[T]) in the moved generator were rewritten as TypeVar/ParamSpec: this library supports Python 3.10 while the node repository they came from targets 3.12. The unit suite runs on 3.10 through 3.13 in CI, and every suite now runs on both ends of that range.
  • The logging.basicConfig(...) every trainer carries is not reproduced. helpers/log_conf.py configures the root logger at import, so basicConfig has been a no-op — checked, not assumed.
  • The batch-size search ships with one consumer. It came out of one node's trainer and that node is still the only caller, so it becomes public API with a single user the moment this is released. That is deliberate rather than an oversight: yolov5_node picks its batch size by string-parsing torchinfo.summary() instead of probing, and replacing that changes which batch size a training gets — it needs a GPU box and has its own card. is_out_of_memory from the same module already has two consumers.
  • No node consumes this yet; the node PRs are separate and stay pinned to 0.21.0 until this is released.

Deliberately not included

  • SubprocessTrainerLogic, a third trainer base class. With a single node as its only consumer, its progress protocol would be invented from one example, and yolov5_node's executor-and-log-scraping shape may not fit it.

⬛ claude-opus-5[1m] · 700k tokens · $16.90

klangenk and others added 6 commits August 20, 2026 12:17
The team-wide part of CONTRIBUTING.md is a copy of templates/CONTRIBUTING.md in
the data_team repository, kept in sync by the sync-style-guide skill. Four repos
held byte-identical copies and classification_node had already drifted, so the
copies were re-synced from one corrected template and the marker heading
convention the skill expects is now documented in the vault.

AGENTS.md gives an agent the project structure, the commands that actually work
here and the traps specific to this repository. It points at CONTRIBUTING.md for
the coding standards instead of repeating them a third time. CLAUDE.md imports
it via @AGENTS.md, matching nicegui, zoe-app and data_team.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
learning_loop_node is public, but AGENTS.md named dfine_node and
classification_node, which are not — neither appears anywhere else in the
public tree. The dependency rule they illustrated holds without naming them,
and "grep ../dfine_node" is advice no external contributor can follow anyway.

Also drop the claim that the shared Linting section contradicts this repo: that
section now covers repositories without a .pre-commit-config.yaml explicitly.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Every main.py in every node repository repeats the same twenty lines: read
UVICORN_RELOAD, build the settings, construct the node, call uvicorn.run.
Eight copies across three repositories, and they had drifted - dfine parses
settings with configargparse so each one is both a flag and an environment
variable, while the others hand-roll os.getenv plus asserts and hardcode the
host and port.

node_parser and run_node put that in one place. A setting is named after its
flag: --conf-threshold reads CONF_THRESHOLD. That is what yolov5_node and
classification_node already use, so adopting this renames nothing in the
repositories that are actually deployed.

--host and --port are the exception, reading NODE_HOST and NODE_PORT. The
bare HOST already means the loop's address - every deployment sets it - and a
node adopting that would hand it to uvicorn and fail to bind.

dfine_node required a DFINE_TRAINER_ / DFINE_DETECTOR_ prefix, so it passes
legacy_env_prefix and its prefixed names keep working with a warning naming
the replacement.

The logging.basicConfig call the trainers carry is not reproduced here - the
library configures the root logger on import, so it has been a no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things every trainer needs, none of which depend on the training
framework, all of which existed only inside dfine_node or in worse form
elsewhere:

iterator_cpu_bound runs a training generator in a spawned process. A trainer
that trains in-process needs this - the event loop must stay responsive and
CUDA state must stay out of the node - and it is pure stdlib.

find_batch_size probes for the largest power-of-two batch that fits, around a
fits predicate the trainer supplies. The search is pure arithmetic; only the
predicate needs a framework, so nothing here imports torch. Three repositories
had three different implementations of this: a real probe, an estimate parsed
out of torchinfo's summary text, and a doubling loop catching bare
RuntimeError. is_out_of_memory exists because of that last one - catching
RuntimeError treats every crash as a full card.

macro_f1 scores the confusion matrix the loop stores, which was implemented
three times over two different data shapes.

_get_executor_error_from_log gets a default implementation, because
yolov5_node and classification_node had byte-identical copies of it.

The PEP 695 type parameters the generator used are rewritten as TypeVar and
ParamSpec: this library supports Python 3.10 and dfine_node targets 3.12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
read_from_env accepts an ordered list of names - ['LOOP_HOST', 'HOST'] and so on
- and returned None when two of them were set to different values. The caller
then fell back to its default, and host()'s default is 'learning-loop.ai': a
.env carrying HOST and LOOP_HOST with different values pointed the node at
production, with only a warning in the log to say so.

Since values is built in the order of possible_names and then filtered, the
first surviving value is already the one the list prefers - so the fix is simply
to warn and fall through to the existing return instead of bailing out.
ignore_errors=False still raises, as before.

This is the right place for the fix. The node repositories' docker.sh scripts
had been working around it by forwarding exactly one spelling, which meant every
sub-project's list had to agree on which one; with the container receiving the
whole .env, only the library can decide.

Adds tests/unit/test_environment_reader.py: the prefixed name wins, either name
alone is read, a disagreement resolves to the preferred name rather than the
default, and ignore_errors=False still raises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@klangenk
klangenk force-pushed the share-generic-node-code branch from f40ab58 to 13c7793 Compare August 25, 2026 10:03
The environment variable table already lists the alias for each loop setting but
said nothing about both being set. That is the case the previous commit changed:
the prefixed name wins, and the variable is never treated as unset, which would
have let LOOP_HOST fall back to learning-loop.ai.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@klangenk
klangenk force-pushed the share-generic-node-code branch 3 times, most recently from 08f1e66 to 8e402d6 Compare August 25, 2026 14:09
category_by_index and category_by_name resolve a model's metadata; they do not
post-process anything. It showed at the one call site outside this library:
classification_node's detector, which produces no boxes at all, imported
category_by_name from a module called postprocess.

They move to detector/categories.py, which leaves postprocess to the job its
name describes - suppression, detection building and conversion to the loop's
dataclasses. Both functions are unchanged, and their tests move with them.

Worth doing now rather than later: these are public API the moment the library
is released, and renaming a module afterwards is a breaking change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from unify-contributing-and-agent-docs to main August 26, 2026 12:12
klangenk and others added 5 commits August 26, 2026 14:34
This repository is public and the example named a node that is not. The prefix a
private node once required is not what the parameter is about, and the tests need
any prefix at all, not that one.

203 unit tests pass, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONTRIBUTING rule 6 wants the main functions at the top and every helper below
its callers, so a reader meets the caller before the callee. Both modules came
from node repositories the other way round: `subprocess.py` had the public
`iterator_cpu_bound` last, below both helpers it calls, and `postprocess.py` had
`_append_detections` above `to_image_metadata` and `to_detections`, which are its
only two callers, while the NMS chain read callee-first throughout.

Pure moves, no line changed - `git show --color-moved` shows it as one. The
reorder was applied by script and asserted to leave the set of lines identical.

203 unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONTRIBUTING rule 10 asks for a module-level logger. The three modules this
branch adds or changes were logging through the root logger, inherited from the
node code they came from: `postprocess.py` twice, `entrypoint.py` twice and
`environment_reader.py` twice, including the warning this branch added.

`getLogger(__name__)` rather than a hand-written name, matching `batch_size.py`
and the newer library modules.

Also modernise `read_from_env`'s annotations while the file is open, which is what
rule 7 asks for and clears three pre-existing ruff findings (`List`, `Optional`).

203 unit tests pass, ruff clean on all four files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A node that used to require an environment prefix had it applied to the *flag*, so `--host` was
read from `<PREFIX>HOST`. `_adopt_legacy_env_vars` derived the legacy name from the current
`env_var` instead, so for `--host` it looked for `<PREFIX>NODE_HOST` -- a name no deployment has
ever set, because the rename to `NODE_HOST` is what this migration introduces.

The effect was worst for exactly the settings the migration renamed: `<PREFIX>HOST` and
`<PREFIX>PORT` were dropped silently, with no value adopted and no deprecation warning, and the
node fell back to binding 0.0.0.0:80. Every setting whose environment variable is simply its flag
in upper case was unaffected, which is why it went unnoticed.

Both spellings are now tried, the prefixed current name first, so a node whose prefixed spelling
already matched keeps working too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@klangenk
klangenk requested a review from jfrieli September 2, 2026 11:58
The node repositories improved these two files after this branch had already
moved them out, so merging main there resolves towards the deletion. The
improvements themselves still belong here, or the move would quietly undo them:

- `Detection` becomes a typed `NamedTuple`. As a bare `namedtuple` its six fields
  had no types at all, which is what dfine_node fixed in #39.
- The failing child process logs through the logger instead of
  `print(traceback.format_exc())`, so a training failure lands in the node's log
  rather than only on stdout. The module logger, not the root one, since that is
  what the rest of this library does.

Assisted-by: Claude:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@klangenk
klangenk marked this pull request as ready for review September 3, 2026 08:30
The docstrings written while moving this code explain why each implementation was
chosen, which CONTRIBUTING rule 4 puts in the pull request rather than in code
that goes stale after the merge; what the code cannot show stays, and the NOTE
block in read_from_env is reduced to its one real point, that possible_names is
ordered by preference. find_batch_size now raises InsufficientMemoryError instead
of the bare RuntimeError that is_out_of_memory exists to disambiguate, Detection's
fields are named at the call site (rule 9), and the docstrings in trainer_logic.py
and exceptions.py use double quotes (rule 1). subprocess.py also drops the claim
that the process is "spawned" - without an explicit context multiprocessing takes
the platform default, fork on Linux.

@jfrieli jfrieli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice PR in general, really looking forward to merge this :)
I still found some points that could be worth to discuss/implement from my side.

Comment thread learning_loop_node/detector/postprocess.py Outdated
Comment thread learning_loop_node/detector/postprocess.py Outdated
Comment thread README.md Outdated
Comment thread learning_loop_node/detector/categories.py
Comment thread learning_loop_node/detector/geometry.py
Comment thread learning_loop_node/trainer/batch_size.py
Comment thread learning_loop_node/trainer/subprocess.py Outdated
Comment thread .github/workflows/pytest.yml Outdated
Comment thread .github/workflows/pytest.yml Outdated
klangenk and others added 12 commits September 4, 2026 17:08
It came from Yolov5TrainerLogic.clip_box, whose only caller was the trainer's
label-parsing pass. That caller now converts to the top-left convention before
handing predictions to to_detections, which clips with clip_box -- so the
centre-based variant would ship as public API with no consumer, for a convention
the library has decided not to use internally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--vram-limit-gb is how a deployment shares one card between processes, and every
trainer that probes for a batch size needs the same two halves of it: the budget the
probe measures against, and the cap that holds the process to it. dfine had both;
a second node would have copied them.

Capping an allocator is a torch operation with no NVML equivalent, so this module
imports torch. The package does not declare it: only a trainer imports the module,
and a trainer brings torch already, so the library keeps installing on machines that
train nothing. The unit test installs a stand-in under the name torch, which covers
the arithmetic and the guards -- whether the cap holds is torch's own business.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README linked docs/writing-a-node.md, which this branch never added. The guide
is worth writing, but not as a passenger in a refactoring change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without a context multiprocessing takes the platform default, which is fork on
Linux -- and forking a process that has already initialised CUDA is what this
helper exists to avoid. dfine enforces spawn globally today; now the helper does
it wherever it runs, and picklability of the callable is a stated requirement
rather than an accident of the platform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
detector_node carried its own find_category_id_by_name, the second name lookup in
the detector. category_by_name raises where this one returned '', so the caller
catches: this path runs on metadata a client uploaded over socket.io, where an
unknown name is the client's error and should cost that detection its id rather
than the whole upload. The warning now names every unknown category once per
upload instead of once per detection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
requires-python promises >=3.10, but 3.11 and 3.12 were tested nowhere -- and 3.12
is what every node but the Jetson detector runs. The suite needs no loop and no
secrets, so covering all four costs only wall-clock on the job that gates the rest.

--locked replaces --frozen so a lockfile that no longer matches pyproject.toml
fails here rather than later, --python names the interpreter instead of taking
whatever PATH offers first, and --no-sync keeps uv run from re-resolving into
something other than what uv sync installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detection read like the base of BoxDetection, PointDetection and
ClassificationDetection, and like the singular of Detections; it is neither. It is
one surviving prediction in the model's own vocabulary -- a class index plus box
geometry -- before category.type decides what it becomes. Prediction says that,
category_index says which of the two identities it carries, and width/height/
confidence match the names the loop's dataclasses already use.

It becomes a frozen keyword-only dataclass: four consecutive numbers built
positionally make a swapped width/height type-correct and silent, and the detector
hot path in yolov5 built it positionally.

post_process now ends in predictions_from_xyxy instead of repeating the conversion,
so there is one pipeline rather than two that had already drifted. Its int() went
with the duplication: truncating here and rounding again in clip_box is strictly
worse than rounding once, and the geometry stays float until the BoxDetection is
built. Point detections gain real precision, since their centre no longer comes
from truncated ints.

Confidence is no longer rounded to two decimals on the way out -- that was never a
contract, and the model's own score is what the loop should see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two jobs split the suites between versions rather than the versions between
suites: general, detector and mock_detector only ever ran on 3.10, annotator,
trainer and mock_trainer only on 3.13. Neither version was actually tested, only
half of each.

One matrix job runs all six on 3.10 and 3.13 -- the ends of what requires-python
promises, with the unit job covering 3.11 and 3.12 as well. max-parallel keeps the
entries serialised: they share one Learning Loop, and test_general creates and
deletes zauberzeug/pytest_nodelib_general by a fixed name, so two entries at once
would delete each other's project. That serialisation used to come from chaining
the two jobs with needs.

Every suite collects on both versions locally; whether they pass needs the loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
find_batch_size arrived here without the measuring around it, so a node adopting it
still had to write the part that is easy to get wrong. dfine_node had written it
twice over -- once for training, once for the detection pass -- and what those two
share is not model-specific at all: hold back a safety margin, reset the peak
counters, run the step, and decide whether a failure means "too big" or "broken".

probe_batch_size is the whole probe for a node whose measurement is a single call.
It resolves the limit == 0 sentinel, falls back without a card, holds the margin for
the length of the search and logs the size it settled on.

measured_fits is that skeleton on its own, for a probe that has to build a throwaway
model before it can measure: the margin has to be claimed before the model is built,
so such a probe composes reserve_margin, measured_fits and find_batch_size itself
rather than handing over a closure. on_out_of_memory is what lets it drop an
optimizer's gradients after a failed trial without also owning the guard.

The guard is the reason this belongs in the library rather than in each node.
isinstance(exc, torch.cuda.OutOfMemoryError) or is_out_of_memory(exc) is one line,
and a node that writes `except RuntimeError: return False` instead reports the
smallest batch size as the card's fault whenever a shape is wrong. Both repositories
that had a probe had their own private copy of that predicate.

MAX_BATCH_SIZE moves in beside NO_GPU_BATCH_SIZE and MIN_TRAIN_STEPS_PER_EPOCH: it
is search policy, not a torch concern, and a node resolving its own sentinel wants
it from the framework-free module. SAFETY_MARGIN stays here, since claiming the
margin is an allocation.

14 tests against the same stand-in for torch, covering what the module decides: the
doubling and the power-of-two rounding, the margin as a share of the budget rather
than of the card, the fallback that runs nothing without a GPU, MemoryError and the
bare RuntimeErrors that cuDNN and cuBLAS raise -- and that a failure which is not
about memory propagates rather than reading as a full card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pass over this branch's own code against CONTRIBUTING.md, after reading it
back rule by rule.

Ordering: cuda.py read bottom-up. probe_batch_size -- which its own docstring
calls the whole probe -- sat fourth, below three functions it calls, and
usable_memory_bytes sat above its only caller. The order is now the call graph,
so a reader meets probe_batch_size first and every callee below all of its
callers. Same rule in the unit tests, where _FakeTorch, the load fixture and
the small predicate helpers sat above the tests they serve.

Defaults: usable_memory_bytes and limit_cuda_memory took device=0, which no
caller ever varied. That is a constant hiding in a signature, and the constant
was wrong for a trainer that pins itself with torch.cuda.set_device(1) -- it
would budget and cap card 0 while training on card 1. Both now let torch
resolve the current device. dfine_node passes neither argument, so nothing
there needs a companion change.

Prose: rules 4 and 7 ask for as few and as short as possible, and no
motivation. cuda.py's module docstring goes from 26 lines to 8, keeping the
two facts the code cannot state and AGENTS.md does not carry: the cap is a
share of the card's total memory rather than of what is free, and torch is
imported but undeclared, so only a trainer may import the module. The
reasoning about fragmentation, the NVML argument and how the pieces compose
already live in AGENTS.md. free_cuda_memory loses its docstring to its own
name; is_out_of_memory keeps the one sentence that justifies matching on
message text. Eight test docstrings and six comments that restated the test
name in other words are gone; the four that state a fact stay -- the bare
RuntimeErrors cuDNN and cuBLAS raise, the test whose passing is the absence of
a hang, where the confusion-matrix numbers come from, and why <PREFIX>HOST
looks like the wrong variable name. The Lightning attribution in batch_size.py
is untouched: that one is a license, not a style choice.

Along the way: test_cuda.py imports relatively like every other test here, its
fake torch records capped fractions instead of (-1.0, -1) sentinels, and the
new pytest.ini loses the trailing whitespace, the "debbuging" typo and the
missing final newline it had inherited from its siblings.

229 unit tests pass. ruff is unchanged at 61 findings across the touched
files, all of them in detector_node.py and trainer_logic.py and all predating
this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matrix tested the two ends of requires-python, 3.10 and 3.13, and so
tested neither version a node runs: dfine_node's and yolov5_node's trainers
are both python:3.12.11-slim-bookworm, and the dfine detector runs on
ubuntu24.04, whose system python is 3.12. The suites that talk to a real loop
are exactly the ones where that gap matters, so 3.13 gives way to 3.12 there.

3.13 keeps its coverage in the unit job, which runs the whole 3.10 through
3.13 range and gates this one -- and no node runs it in production yet. The
floor stays, since requires-python promises it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h used

When find_batch_size raised, the margin tensor stayed referenced by the
traceback until the caller's except block ended, so the reserved share was
still held while a fallback ran. The release now sits in a finally.

The limit is rounded down to a power of two once, before the search; the log
line printed the raw value, so a limit of 12 read as if 12 had been tried.
klangenk and others added 4 commits September 9, 2026 11:09
The ruleset requires pytest_3_10 and pytest_3_13, which were job ids until the two
jobs became one matrix job. A matrix job's check run is named "<job> (<entry>)", so
both contexts stopped existing -- and an unreported required check is not a failure
but "expected", which waits forever. Every PR carrying the refactor is stuck on a
status that nothing can report, this one included.

all-green needs both jobs and passes only when both succeeded, so the ruleset can
name it instead and stop tracking the matrix. always() plus an explicit test of each
result is what makes it a gate: a job that is skipped because its dependency failed
reports no status at all, and a required check that never reports blocks rather than
fails.

Swapping the two contexts for all-green in the ruleset needs a repo admin; until then
this changes nothing about what CI runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@klangenk
klangenk merged commit c4eda0f into main Sep 10, 2026
19 of 24 checks passed
@klangenk
klangenk deleted the share-generic-node-code branch September 10, 2026 05:26
klangenk added a commit to zauberzeug/yolov5_node that referenced this pull request Sep 16, 2026
## Motivation

Both detectors and the trainer carried their own copies of code that has
nothing to do with YOLOv5. `clip_point` was byte-identical in three
places; `clip_box` in two, plus a fourth copy in the trainer under the
same name but with a centre-based convention. `bbox_iou` was duplicated
between the two detectors, and the detection-building loop existed
twice.

All of it now comes from `learning_loop_node.detector`, leaving only
what is genuinely YOLOv5: the packed `[cx,cy,w,h,conf,probs...]` layout,
the letterbox correction in `xywh2xyxy`, and the tensorrtx engine build.

**285 lines removed for 33 added, plus the `docker.sh` consolidation.**
The commits carry the reasoning in full; this is the summary.

**Depends on zauberzeug/learning_loop_node#94**, which is merged and
released: all three sub-projects now pin `learning_loop_node==0.23.0`,
so the imports resolve.

## Implementation

- `detector/yolov5.py`: remove the local `Detection` namedtuple and
`bbox_iou`, including its `x1y1x2y2=False` branch, dead at every call
site.
- `detector/yolov5_detector.py`: remove `clip_box`, `clip_point`,
`MIN_BOX_SIZE` and `_collect_detections` — the last was exactly the
library's `to_image_metadata`.
- `detector/yolov5_detector.py`: also fix `batch_evaluate`, which
clipped every result in a batch against `images[0].shape`. `infer_batch`
letterboxes each image separately and already undoes that with the right
per-image size, so a batch mixing resolutions clipped the later images
against the first one's bounds. Predates this branch; the line moved
here, which is how it turned up.
- `detector_cpu/yolov5_detector.py`: same removals; `evaluate` builds
detections with `predictions_from_xyxy` + `to_image_metadata` instead of
a 40-line inline loop, and the detection cap becomes a named
`MAX_DETECTIONS`.
- `trainer/app_code/yolov5_trainer.py`: `_parse_file` returns
`Prediction` values with the label file's centre-based coordinates
converted to the top-left corner `to_detections` expects, so the local
centre-based `clip_box` and `clip_point` go away and the clipping
happens in the library. Note that this also subjects the auto-detections
to the shared `MIN_BOX_SIZE` filter, which the trainer never had -- see
*Behaviour changes*. Also inherit `_get_executor_error_from_log`, which
was byte-identical to another node's.
- The three `main.py` use `node_parser` / `run_node`: every setting is a
flag *and* an environment variable, `--help` lists them, and argparse
`choices` replaces the hand-written asserts.
- Replace the three `docker.sh` scripts with a five-line wrapper over
`scripts/node-docker.sh` plus a `docker.conf` per sub-project.
`./docker.sh` from inside a sub-project is unchanged.

## No environment variable is renamed

`WEIGHT_TYPE`, `IOU_THRESHOLD`, `CONF_THRESHOLD` and
`BUILDER_OPTIMIZATION_LEVEL` keep the names your `.env` files and
`face_detection/app_code/docker-compose.detectors.yml` already set.
**This is the widely deployed node, so it does not move**; the other
nodes migrate instead. `--host`/`--port` are new, reading
`NODE_HOST`/`NODE_PORT` — the bind address was hardcoded before.

## Behaviour changes

Two in `detector_cpu`, both bringing it in line with the GPU detector:

- Boxes of 2 px or less are dropped; `detector_cpu` was the only
detector not filtering them.
- A category index outside the model's categories raises instead of
logging and skipping. A model/metadata mismatch makes every detection on
that image suspect.

And one in the trainer, from going through the same converter:

- Auto-detections of 2 px or less are dropped, where `_parse_file` used
to upload them. Surviving boxes also move by up to 1 px, because the
corner and size are now rounded rather than truncated.

Plus one fix in `docker.sh`: `grep -oP` is a GNU extension, so on any
host where BSD grep comes first every image was tagged `:-nlv`. Now
`sed`. `HOST_PORT` makes ports overridable (defaults unchanged),
`CONTAINER_NAME` replaces `DETECTOR_NAME`/`TRAINER_NAME` with the old
spelling still accepted, and every name declared in `.env` is forwarded
to the container as `--env NAME`, so it arrives with the value the shell
evaluated. (`--env-file .env` was the first attempt; docker's env-file
reader keeps quotes and expands nothing, so `PASSWORD='secret'` would
have reached the container quoted and an int-typed setting would not
have parsed at all — caught in review.)

## Verified

Each removed implementation was checked against its replacement —
`_collect_detections` vs `to_image_metadata`, `bbox_iou` over 50 random
boxes, both clip functions, and `detector_cpu`'s inline building — all
identical output. `docker.sh` was swept across 12 subcommands × 4 `.env`
variants (documented, quoted, awkward, absent) per sub-project, diffing
both the emitted `docker` command and the environment the container ends
up with against the previous script.

`ruff` findings go **down**: 12 → 7 in `detector_cpu`, 9 → 7 in
`trainer`, 2 → 1 in `detector`; the rest is pre-existing. The
environments cannot be installed on macOS, so linting used `uvx ruff`
and the equivalence checks ran against the library checkout. Committed
with `--no-verify`.

⚠️ **The Jetson path has never been executed.** `detector/docker.conf`'s
`select_image` — base image, tag suffix, `LINKLL` path and the L4T
version parse — was reasoned through on a machine with no
`/etc/nv_tegra_release`. The L4T parse was checked against the R32, R35
and R36 formats, but **one build on real hardware is wanted before
merge.**

## Deliberately not included

`app_code/batch_size_calculation.py` still estimates the batch size by
string-parsing `torchinfo.summary`'s output, where the shared
`find_batch_size` would do a real probe. Changing how a batch size is
chosen — unverifiable here, in a repository whose trainer tests need a
GPU and a live loop — is how a training silently degrades weeks later.
It wants someone who can run one.

---
⬛ claude-opus-5[1m] · 710k tokens · $17.10

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: jan <jan@zauberzeug.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants