🔎 Inference¶
Turning a trained model's raw outputs into detections: loading a checkpoint, one-call prediction for a single image per task, scoring on COCO, and the decoders behind them, NMS-free top-k beside the NMS baseline. Generated from the docstrings in src/lucid_yolo/predict.py, src/lucid_yolo/eval/ and src/lucid_yolo/decode/. The names in the first three sections are also importable from the top-level package, lazily (lucid_yolo.load_eval_module, lucid_yolo.predict_image, lucid_yolo.score_coco), so import lucid_yolo loads neither torch nor Lightning until one of them is used. Using a checkpoint from Python shows them together.
Loading a checkpoint¶
load_eval_module
¶
load_eval_module(checkpoint: Path, *, use_ema: bool) -> tuple[DetectionLitModule, dict[str, object]]
Load an eval-mode module from a Lightning checkpoint, optionally with EMA weights.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
checkpoint
|
Path
|
Path to the Lightning |
required |
use_ema
|
bool
|
When |
required |
Returns:
| Type | Description |
|---|---|
tuple[DetectionLitModule, dict[str, object]]
|
The eval-mode module and a small provenance dict (checkpoint, epoch, step, EMA). |
Raises:
| Type | Description |
|---|---|
UnpicklingError
|
If the checkpoint names anything restricted loading refuses. Raised by the one read, before any reducer the file carries is called -- see the module docstring. |
ValueError
|
If the checkpoint carries no |
Examples:
pick_device
¶
Resolve auto to the fastest available backend, else check requested exists here.
A named backend is held to the same availability question auto asks, rather than passed through (WP-171).
--device cuda on a machine with no CUDA used to be accepted and then fail several minutes later, inside the
first .to(device) — after the checkpoint was loaded and, for a long run, after the annotations were parsed —
with a message about a tensor rather than about the flag. The refusal names the request and what this machine does
have, because the useful next command is the one the caller cannot guess from a stack trace.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
requested
|
str
|
|
required |
Returns:
| Type | Description |
|---|---|
device
|
The chosen device. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
Prediction¶
Each function takes the image as a file path or as a decoded (3, H, W) RGB tensor, one image per call.
predict_image
¶
predict_image(module: DetectionLitModule, image: Path | Tensor, img_size: int = COCO_IMG_SIZE, decoder: DecodePath = 'e2e', conf_threshold: float = DEFAULT_CONF_THRESHOLD, device: device | None = None) -> Tensor
Detect objects in one image and return them in original-image coordinates.
The image is letterboxed into an img_size square canvas, run through the module once, decoded by the selected
path, and mapped back onto the original pixel grid by the exact analytic inverse of the letterbox that produced
the canvas (A10). Rows below conf_threshold are dropped, which also removes the score-zero padding rows both
decoders emit to keep their output shape fixed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
module
|
DetectionLitModule
|
An eval-mode |
required |
image
|
Path | Tensor
|
Path to the image file to read, or an already-decoded |
required |
img_size
|
int
|
Letterbox side the model sees. Defaults to |
COCO_IMG_SIZE
|
decoder
|
DecodePath
|
|
'e2e'
|
conf_threshold
|
float
|
Detections at or below this score are dropped. Defaults to :data: |
DEFAULT_CONF_THRESHOLD
|
device
|
device | None
|
Device to run on. Defaults to CPU; the command resolves |
None
|
Returns:
| Type | Description |
|---|---|
Tensor
|
A CPU tensor of shape |
Tensor
|
in original-image pixels, score-descending. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the module's task is not |
ValueError
|
If a tensor |
TypeError
|
If a tensor |
Examples:
predict_segmentation
¶
predict_segmentation(module: DetectionLitModule, image: Path | Tensor, img_size: int = COCO_IMG_SIZE, decoder: DecodePath = 'e2e', conf_threshold: float = DEFAULT_CONF_THRESHOLD, device: device | None = None) -> SegmentedPrediction
Segment objects in one image and return boxes and masks in original coordinates.
:func:predict_image plus Eq. 7, in the order :class:~lucid_yolo.eval.coco_eval.DualPathEvaluator established
and for its reason: the model runs once, the selected path decodes boxes and reports the anchor each row came
from, each surviving detection's mask coefficients are gathered by that index, the masks are assembled while the
boxes are still on the letterboxed canvas — which is the frame
:func:~lucid_yolo.eval.segment_decode.decode_instance_masks crops in — and only then are boxes and masks each
landed on the original pixel grid. Cropping after the inverse letterbox would leave every box right and every mask
quietly wrong.
Rows below conf_threshold are dropped before the masks are assembled, which is where this differs from the
evaluator: the evaluator needs a fixed-length output per image and pays for a mask per padding row, while a caller
looking at one picture wants a mask per object. At 640 px the difference is 300 canvas-resolution masks instead of
a handful, which is measured in hundreds of megabytes of transient float.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
module
|
DetectionLitModule
|
An eval-mode |
required |
image
|
Path | Tensor
|
Path to the image file to read, or an already-decoded |
required |
img_size
|
int
|
Letterbox side the model sees. Defaults to |
COCO_IMG_SIZE
|
decoder
|
DecodePath
|
|
'e2e'
|
conf_threshold
|
float
|
Detections at or below this score are dropped, with their masks.
Defaults to :data: |
DEFAULT_CONF_THRESHOLD
|
device
|
device | None
|
Device to run on. Defaults to CPU; the command resolves |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
SegmentedPrediction
|
class: |
SegmentedPrediction
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the module's task is not |
ValueError
|
If a tensor |
TypeError
|
If a tensor |
Examples:
predict_oriented
¶
predict_oriented(module: DetectionLitModule, image: Path | Tensor, img_size: int = DEFAULT_ORIENTED_IMG_SIZE, decoder: DecodePath = ORIENTED_DECODE_PATH, conf_threshold: float = DEFAULT_CONF_THRESHOLD, device: device | None = None) -> Tensor
Detect oriented objects in one image and return them in original coordinates.
:func:predict_image's shape with R1 Eq. 13 in it, and the same pieces the per-tile
oriented evaluator (:mod:lucid_yolo.eval.rotated_eval) is built from rather than a
second reading of them: :func:~lucid_yolo.models.heads.obb.decode_rboxes turns the
one-to-one branch's ltrb distances and raw angles into canonical rotated boxes,
:class:~lucid_yolo.decode.topk_e2e.RotatedTopKDecoder ranks them, and
:func:~lucid_yolo.decode.common.rboxes_to_letterboxed_original lands them on the
original pixel grid. Nothing here computes a pad, a ratio or a corner.
The column convention is A45, the one the oriented evaluator and
:func:~lucid_yolo.eval.dota_eval.rotated_detections_to_predictions already consume:
[cx, cy, w, h, theta, score, class], which is the long-edge rotated box of
:mod:lucid_yolo.data.rotated_geom followed by the two columns the A9 tuple ends
with. It is not the A9 tuple with an angle appended — the four box columns are a
centre and two extents, not corners — so a caller that slices [:, :4] expecting
xyxy gets a plausible, wrong rectangle. :data:~lucid_yolo.decode.common.RBOX_COLUMNS
names where the box columns end and the score begins.
Canonicalization is post-decode, and that is the right seam (A23). Two places
could hold it and both are worse. In the head, it would normalize an angle the loss
is still training against a raw target, and R1 Eq. 13 explicitly emits the
pre-activation. Here, after the letterbox inverse, it would run once per entry point
— this one, the evaluator, an export graph — and each copy would be free to drift.
Inside :func:~lucid_yolo.models.heads.obb.decode_rboxes it runs on the dense
(B, A, 5) output, so every consumer of a decoded rotated box inherits w >= h
and theta in [-pi/4, 3*pi/4) unconditionally, including one that never ranks
and never letterboxes. What this function then adds is the proof that the guarantee
survives the trip home: a letterbox inverse is one isotropic scale plus a translation,
which scales both extents by the same positive number and turns no angle, so the
canonical form is invariant under it and is not recomputed here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
module
|
DetectionLitModule
|
An eval-mode |
required |
image
|
Path | Tensor
|
Path to the image file to read, or an already-decoded |
required |
img_size
|
int
|
Letterbox side the model sees. Defaults to
:data: |
DEFAULT_ORIENTED_IMG_SIZE
|
decoder
|
DecodePath
|
|
ORIENTED_DECODE_PATH
|
conf_threshold
|
float
|
Detections at or below this score are dropped. Defaults to
:data: |
DEFAULT_CONF_THRESHOLD
|
device
|
device | None
|
Device to run on. Defaults to CPU; the command resolves |
None
|
Returns:
| Type | Description |
|---|---|
Tensor
|
A CPU tensor of shape |
Tensor
|
|
Tensor
|
score-descending, canonical ( |
Tensor
|
measured from |
Tensor
|
contiguous class index (see the module docstring). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the module's task is not |
ValueError
|
If a tensor |
TypeError
|
If a tensor |
Examples:
predict_keypoints
¶
predict_keypoints(module: DetectionLitModule, image: Path | Tensor, img_size: int = COCO_IMG_SIZE, decoder: DecodePath = 'e2e', conf_threshold: float = DEFAULT_CONF_THRESHOLD, device: device | None = None) -> KeypointPrediction
Locate objects and their points in one image, in original-image coordinates.
:func:predict_image plus the point branch, composed the way
:func:predict_segmentation composes the mask branch and for the same reason: the
point stem is dense over anchors, so the selected path must report the anchor
each surviving row came from and the points are gathered by that index. The
decode-then-gather composition is
:func:~lucid_yolo.decode.per_task.decode_with_keypoints, the one
:class:~lucid_yolo.export.KeypointExportGraph and the evaluator decode through too.
Points are mapped back with
:func:~lucid_yolo.decode.common.points_to_letterboxed_original -- the same exact
analytic inverse the boxes take (A10), applied to the point axis flattened into a
plain point list, so a point and the box around it cannot land on two different grids.
K is read from the decoded tensor, never assumed. The head's point count is a
constructor argument (A64) and COCO's 17 is one instantiation of it; a function that
assumed 17 would silently mis-shape every other schema.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
module
|
DetectionLitModule
|
An eval-mode |
required |
image
|
Path | Tensor
|
Path to the image file to read, or an already-decoded |
required |
img_size
|
int
|
Letterbox side the model sees. Defaults to |
COCO_IMG_SIZE
|
decoder
|
DecodePath
|
|
'e2e'
|
conf_threshold
|
float
|
Detections at or below this score are dropped, with their
points. Defaults to :data: |
DEFAULT_CONF_THRESHOLD
|
device
|
device | None
|
Device to run on. Defaults to CPU; the command resolves |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
KeypointPrediction
|
class: |
KeypointPrediction
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the module's task is not |
ValueError
|
If a tensor |
TypeError
|
If a tensor |
Examples:
Scoring¶
score_coco
¶
score_coco(module: DetectionLitModule, data_root: Path | None = None, *, ann_file: Path | None = None, images_dir: Path | None = None, img_size: int = COCO_IMG_SIZE, batch_size: int = _DEFAULT_BATCH_SIZE, device: str = 'auto', limit: int = 0, masks: bool = True, eval_backend: str = 'faster_coco_eval') -> CocoScore
Score a checkpoint on COCO val2017 through both decode paths and return the numbers.
The protocol is the one lucid-eval runs, chosen by the module's own task: box mAP for detect, box
plus segm_ mask mAP for segment (box only with masks=False), box plus oks_ keypoint mAP against
person_keypoints_val2017.json for keypoints. Nothing is printed and no file is written.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
module
|
DetectionLitModule
|
An eval-mode module, as
:func: |
required |
data_root
|
Path | None
|
COCO root holding |
None
|
ann_file
|
Path | None
|
Annotation JSON. Defaults to |
None
|
images_dir
|
Path | None
|
Image directory. Defaults to |
None
|
img_size
|
int
|
Letterbox side, a positive multiple of every head stride. Defaults to
|
COCO_IMG_SIZE
|
batch_size
|
int
|
Images per forward pass. Defaults to |
_DEFAULT_BATCH_SIZE
|
device
|
str
|
|
'auto'
|
limit
|
int
|
Score only the first N images; |
0
|
masks
|
bool
|
For a segmentation checkpoint, whether to score masks too. |
True
|
eval_backend
|
str
|
Box/mask scoring engine, |
'faster_coco_eval'
|
Returns:
| Type | Description |
|---|---|
CocoScore
|
The two paths' statistics, the scored image count and the scoring time. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the module's task is not |
Examples:
>>> from pathlib import Path
>>> from lucid_yolo.eval.checkpoint import load_eval_module
>>> module, _ = load_eval_module(Path("det.ckpt"), use_ema=True) # needs a checkpoint # doctest: +SKIP
>>> score = score_coco(module, Path("~/data/coco2017").expanduser()) # needs COCO # doctest: +SKIP
>>> score.report["e2e"]["map"] # doctest: +SKIP
0.3912
CocoScore
dataclass
¶
The dual-path COCO report of one checkpoint, as numbers rather than printed text.
Attributes:
| Name | Type | Description |
|---|---|---|
report |
dict[str, dict[str, float]]
|
|
images |
int
|
How many images were scored, after any |
seconds |
float
|
Wall time of the scoring pass, excluding the annotation load. |
Examples:
>>> score = CocoScore(report={"e2e": {"map": 0.31}, "nms": {"map": 0.32}}, images=5000, seconds=90.0)
>>> round(score.report["nms"]["map"] - score.report["e2e"]["map"], 2)
0.01
Decoders¶
TopKDecoder
¶
Bases: Module
Score-based top-k end-to-end decoder for the one-to-one branch (A9).
Turns the one-to-one branch's raw dense outputs into a fixed-size batch of score-ranked detections without any IoU
computation or non-maximum suppression (R3 sec. 4). The module owns no parameters — it is a pure functional
transform wrapped as an :class:~torch.nn.Module so it composes into an inference graph and traces/exports cleanly
with a static output shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
k
|
int
|
Maximum detections kept per image; also the fixed output length. Defaults to 300 (R1 sec. 3.2.1, R3 sec. 4). |
MAX_DETECTIONS
|
conf_threshold
|
float
|
Detections whose confidence is strictly below this value have their score zeroed (the row is
kept so the output shape is fixed). Defaults to |
0.0
|
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(2, 2)], [8])
>>> cls_logits = torch.full((1, 4, 3), -5.0)
>>> cls_logits[0, 1, 2] = 5.0 # one confident anchor, class 2
>>> raw_ltrb = torch.ones(1, 4, 4)
>>> decoder = TopKDecoder(k=2)
>>> detections = decoder(cls_logits, raw_ltrb, points, strides)
>>> detections.shape # fixed (B, k, 6) even though only 4 anchors exist
torch.Size([1, 2, 6])
>>> int(detections[0, 0, 5]) # class of the top detection
2
forward
¶
Decode raw one-to-one outputs into fixed-size top-k detections.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls_logits
|
Tensor
|
Raw one-to-one class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw one-to-one ltrb distances of shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
Detections of shape |
Tensor
|
sorted by descending score with score-zero padding rows filling any shortfall below |
Examples:
decode_with_indices
¶
decode_with_indices(cls_logits: Tensor, raw_ltrb: Tensor, anchor_points: Tensor, strides: Tensor) -> tuple[Tensor, Tensor]
Decode as :meth:forward does, and report the source anchor of each row.
The whole decode lives here and :meth:forward is its box-only view, so the two cannot report different
selections. The extra return is what any per-anchor quantity outside the A9 tuple must be gathered by — the
segmentation decode's mask coefficients (WP-053b): a coefficient row taken from a differently ranked selection
would yield a plausible mask of the wrong object beside a correct box.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls_logits
|
Tensor
|
Raw one-to-one class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw one-to-one ltrb distances of shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
A pair |
Tensor
|
|
tuple[Tensor, Tensor]
|
data: |
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits = torch.tensor([[[-5.0], [5.0]]]) # anchor 1 is the confident one
>>> raw_ltrb = torch.zeros(1, 2, 4)
>>> detections, anchors = TopKDecoder(k=3).decode_with_indices(
... cls_logits, raw_ltrb, points, strides
... )
>>> detections.shape
torch.Size([1, 3, 6])
>>> anchors # anchor 1 ranks first; the third row is padding
tensor([[ 1, 0, -1]])
RotatedTopKDecoder
¶
Bases: Module
Score-based top-k end-to-end decoder for the one-to-one branch's oriented outputs (A45).
The oriented twin of :class:TopKDecoder, and deliberately the same contract: the one-to-one branch's raw ltrb
distances and raw angles (R1 Eq. 13) become canonical rotated boxes through
:func:~lucid_yolo.models.heads.obb.decode_rboxes (A23), the anchors are ranked purely by classification
confidence through :func:~lucid_yolo.models.heads.obb.o2o_rotated_topk_with_indices — no overlap is computed
and no box is dropped for overlapping another — and the result is padded to exactly k rows, the anchor indices
beside it padded with :data:~lucid_yolo.decode.common.PAD_ANCHOR_INDEX.
The padding is what this class adds over calling the ranking directly, which returns min(k, A) rows: on a
canvas with fewer than k anchors (a side of 96 px or less at the three head strides) the row count would
otherwise depend on the canvas, unlike every other decoder's. On a canvas with at least k anchors the k
rows are the ranking's own, bit for bit. Rows beyond the available anchors carry score == 0, so a caller
filtering on score drops them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
k
|
int
|
Maximum detections kept per image; also the fixed output length. Defaults to
:data: |
MAX_DETECTIONS
|
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits = torch.tensor([[[-5.0], [5.0]]]) # anchor 1 is the confident one
>>> raw_ltrb = torch.ones(1, 2, 4)
>>> raw_angles = torch.zeros(1, 2, 1)
>>> detections = RotatedTopKDecoder(k=3)(cls_logits, raw_ltrb, raw_angles, points, strides)
>>> detections.shape # fixed (B, k, 7) even though only 2 anchors exist
torch.Size([1, 3, 7])
>>> detections[0, 2] # the padding row: score zero, no box
tensor([0., 0., 0., 0., 0., 0., 0.])
forward
¶
forward(cls_logits: Tensor, raw_ltrb: Tensor, raw_angles: Tensor, anchor_points: Tensor, strides: Tensor) -> Tensor
Decode raw one-to-one outputs into fixed-size top-k oriented detections.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls_logits
|
Tensor
|
Raw one-to-one class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw one-to-one ltrb distances of shape |
required |
raw_angles
|
Tensor
|
Raw one-to-one angles in radians, shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
Detections of shape |
Tensor
|
|
Tensor
|
filling any shortfall below |
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits, raw_ltrb, raw_angles = torch.zeros(1, 2, 1), torch.zeros(1, 2, 4), torch.zeros(1, 2, 1)
>>> RotatedTopKDecoder(k=5)(cls_logits, raw_ltrb, raw_angles, points, strides).shape
torch.Size([1, 5, 7])
decode_with_indices
¶
decode_with_indices(cls_logits: Tensor, raw_ltrb: Tensor, raw_angles: Tensor, anchor_points: Tensor, strides: Tensor) -> tuple[Tensor, Tensor]
Decode as :meth:forward does, and report the source anchor of each row.
The whole decode lives here and :meth:forward is its detections-only view, as in :class:TopKDecoder, so the
two cannot report different selections. The indices are what any per-anchor value outside the A45 tuple is
gathered by.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls_logits
|
Tensor
|
Raw one-to-one class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw one-to-one ltrb distances of shape |
required |
raw_angles
|
Tensor
|
Raw one-to-one angles in radians, shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
A pair |
Tensor
|
meth: |
tuple[Tensor, Tensor]
|
row came from, with :data: |
tuple[Tensor, Tensor]
|
padding rows. |
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits = torch.tensor([[[-5.0], [5.0]]])
>>> raw_ltrb, raw_angles = torch.ones(1, 2, 4), torch.zeros(1, 2, 1)
>>> decoder = RotatedTopKDecoder(k=3)
>>> _, anchors = decoder.decode_with_indices(cls_logits, raw_ltrb, raw_angles, points, strides)
>>> anchors # anchor 1 ranks first; the third row is padding
tensor([[ 1, 0, -1]])
NMSDecoder
¶
Bases: Module
Confidence-threshold + class-wise NMS decoder for the dense branch (A9).
Turns the one-to-many branch's raw dense outputs into a fixed-size batch of score-ranked detections via a
confidence threshold and class-wise non-maximum suppression (the non-E2E path, blueprint sec. 5.5). Emits the same
(B, max_det, 6) A9 tuple as :class:~lucid_yolo.decode.topk_e2e.TopKDecoder so both paths feed one evaluator.
The module owns no parameters — it is a pure functional transform wrapped as an :class:~torch.nn.Module.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
conf_threshold
|
float
|
Anchors whose best-class score is strictly below this value are dropped before NMS. Defaults
to |
EVAL_CONF_THRESHOLD
|
iou_threshold
|
float
|
Class-wise NMS IoU threshold; a lower-scoring box is suppressed when its IoU with a kept
higher-scoring box of the same class exceeds this. Defaults to |
DEFAULT_IOU_THRESHOLD
|
max_det
|
int
|
Maximum detections kept per image; also the fixed output length.
Defaults to |
MAX_DETECTIONS
|
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(2, 2)], [8])
>>> cls_logits = torch.full((1, 4, 3), -5.0)
>>> cls_logits[0, 1, 2] = 5.0 # one confident anchor, class 2
>>> raw_ltrb = torch.ones(1, 4, 4)
>>> decoder = NMSDecoder(max_det=100)
>>> detections = decoder(cls_logits, raw_ltrb, points, strides)
>>> detections.shape # fixed (B, max_det, 6)
torch.Size([1, 100, 6])
>>> int(detections[0, 0, 5]) # class of the top detection
2
forward
¶
Decode raw dense outputs into fixed-size NMS-filtered detections.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls_logits
|
Tensor
|
Raw dense class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw dense ltrb distances of shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
Detections of shape |
Tensor
|
sorted by descending score with score-zero padding rows filling any shortfall below |
Examples:
decode_with_indices
¶
decode_with_indices(cls_logits: Tensor, raw_ltrb: Tensor, anchor_points: Tensor, strides: Tensor) -> tuple[Tensor, Tensor]
Decode as :meth:forward does, and report the source anchor of each row.
The whole decode lives here and :meth:forward is its box-only view, so the two cannot report different
survivor sets. The extra return exists for the same reason as its one-to-one twin
(:meth:~lucid_yolo.decode.topk_e2e.TopKDecoder.decode_with_indices): the segmentation decode (WP-053b) gathers
each detection's mask coefficients from the dense (B, A, K) map by these indices. On this path the kept set
is chosen by the threshold and the suppression, not by a ranking any caller could reproduce, so the indices
have to be carried out through the same keep/order selection that built the boxes — recomputing a
second selection outside is exactly how a mask ends up describing another anchor's object.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls_logits
|
Tensor
|
Raw dense class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw dense ltrb distances of shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
A pair |
Tensor
|
|
tuple[Tensor, Tensor]
|
data: |
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits = torch.tensor([[[-5.0], [5.0]]]) # only anchor 1 clears the threshold
>>> raw_ltrb = torch.full((1, 2, 4), 0.25) # a real 4 px box on each anchor
>>> decoder = NMSDecoder(conf_threshold=0.5, max_det=3)
>>> detections, anchors = decoder.decode_with_indices(cls_logits, raw_ltrb, points, strides)
>>> detections.shape
torch.Size([1, 3, 6])
>>> anchors # anchor 1 survives; the remaining rows are padding
tensor([[ 1, -1, -1]])
A box has to enclose an area to be a detection: zero ltrb distances put x2 exactly on x1, and
such a row is dropped with the sub-threshold ones rather than spending one of the max_det slots.
>>> flat = torch.zeros(1, 2, 4)
>>> _, none_kept = decoder.decode_with_indices(cls_logits, flat, points, strides)
>>> none_kept
tensor([[-1, -1, -1]])
An empty batch decodes to empty outputs of the documented width rather than raising, matching
:class:~lucid_yolo.decode.topk_e2e.TopKDecoder:
RotatedNMSDecoder
¶
Bases: Module
Confidence-threshold + class-wise rotated-NMS decoder for the dense branch (A45, A61).
Turns the one-to-many branch's dense oriented outputs into a fixed-size batch of score-ranked oriented
detections, suppressing by exact rotated overlap rather than by the overlap of the boxes' upright envelopes. Emits
the same (B, max_det, 7) A45 tuple as :func:~lucid_yolo.models.heads.obb.o2o_rotated_topk, so the
suppression column and the suppression-free column are the same kind of number. The module owns no parameters — it
is a pure functional transform wrapped as an :class:~torch.nn.Module.
This is the o2m path's decoder and a comparison baseline. It is not a repair to the one-to-one path, whose freedom from NMS is the architecture's claim rather than a limitation to work around.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
conf_threshold
|
float
|
Anchors whose best-class score is strictly below this value are dropped before suppression.
Defaults to |
EVAL_CONF_THRESHOLD
|
iou_threshold
|
float
|
Rotated-IoU threshold; a lower-scoring box is suppressed when its rotated IoU with a kept
higher-scoring box of the same class exceeds this. Defaults to :data: |
ROTATED_NMS_IOU_THRESHOLD
|
max_det
|
int
|
Maximum detections kept per image; also the fixed output length. Defaults to |
MAX_DETECTIONS
|
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(2, 2)], [8])
>>> cls_logits = torch.full((1, 4, 3), -5.0)
>>> cls_logits[0, 1, 2] = 5.0 # one confident anchor, class 2
>>> raw_ltrb = torch.ones(1, 4, 4)
>>> angles = torch.zeros(1, 4, 1)
>>> decoder = RotatedNMSDecoder(max_det=100)
>>> detections = decoder(cls_logits, raw_ltrb, angles, points, strides)
>>> detections.shape # fixed (B, max_det, 7)
torch.Size([1, 100, 7])
>>> int(detections[0, 0, 6]) # class of the top detection
2
forward
¶
forward(cls_logits: Tensor, raw_ltrb: Tensor, angles: Tensor, anchor_points: Tensor, strides: Tensor) -> Tensor
Decode raw dense oriented outputs into fixed-size rotated-NMS-filtered detections.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls_logits
|
Tensor
|
Raw dense class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw dense ltrb distances of shape |
required |
angles
|
Tensor
|
Raw dense orientation angles in radians, shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
Detections of shape |
Tensor
|
|
Tensor
|
by descending score, with score-zero padding rows filling any shortfall below |
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits = torch.zeros(1, 2, 1)
>>> raw_ltrb = torch.zeros(1, 2, 4)
>>> angles = torch.zeros(1, 2, 1)
>>> RotatedNMSDecoder(max_det=5)(cls_logits, raw_ltrb, angles, points, strides).shape
torch.Size([1, 5, 7])
>>> # Nothing above the threshold still returns the fixed shape, all padding.
>>> empty = RotatedNMSDecoder(conf_threshold=0.99, max_det=2)
>>> empty(cls_logits, raw_ltrb, angles, points, strides)
tensor([[[0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0.]]])
>>> # An empty batch decodes to an empty result of the documented width.
>>> zero = torch.zeros(0, 2, 1)
>>> empty(zero, torch.zeros(0, 2, 4), zero, points, strides).shape
torch.Size([0, 2, 7])
Decode helpers¶
The per-task decode compositions that prediction, evaluation and export share, and the constants and gathers beneath them.
decode_with_coefficients
¶
decode_with_coefficients(decoder: IndexingDecoder, cls_logits: Tensor, raw_ltrb: Tensor, coefficients: Tensor, anchor_points: Tensor, strides: Tensor) -> tuple[Tensor, Tensor, Tensor]
Decode one branch's boxes and gather each row's mask coefficients by its own anchor.
The segmentation half of Eq. 7 before the prototypes: decoder ranks the branch and reports each row's source
anchor, and :func:~lucid_yolo.decode.common.gather_by_anchor takes that anchor's coefficient vector. All four
inputs must come from the same branch — the decoder's ranking of one branch paired with the other branch's
coefficients is a plausible mask of a different object at a correct box.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
decoder
|
IndexingDecoder
|
The path's decoder, e.g.
:class: |
required |
cls_logits
|
Tensor
|
Raw class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw ltrb distances of shape |
required |
coefficients
|
Tensor
|
The same branch's mask coefficients, shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
A row-aligned triple: the decoder's |
Tensor
|
|
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> from lucid_yolo.decode.topk_e2e import TopKDecoder
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits = torch.tensor([[[-5.0], [5.0]]]) # anchor 1 is the confident one
>>> coefficients = torch.tensor([[[1.0, 1.0], [2.0, 2.0]]])
>>> _, anchors, gathered = decode_with_coefficients(
... TopKDecoder(k=3), cls_logits, torch.ones(1, 2, 4), coefficients, points, strides
... )
>>> anchors
tensor([[ 1, 0, -1]])
>>> gathered # anchor 1's vector first, the padding row zeroed
tensor([[[2., 2.],
[1., 1.],
[0., 0.]]])
decode_with_keypoints
¶
decode_with_keypoints(decoder: IndexingDecoder, cls_logits: Tensor, raw_ltrb: Tensor, raw_points: Tensor, anchor_points: Tensor, strides: Tensor) -> tuple[Tensor, Tensor, Tensor]
Decode one branch's boxes and take each row's decoded point set by its own anchor.
:func:decode_with_coefficients for the point stem: decoder ranks the branch and :func:gather_keypoint_sets
decodes that branch's dense offsets and takes each kept row's set. All inputs must come from the same branch, for
the reason :func:decode_with_coefficients states.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
decoder
|
IndexingDecoder
|
The path's decoder, e.g.
:class: |
required |
cls_logits
|
Tensor
|
Raw class logits of shape |
required |
raw_ltrb
|
Tensor
|
Raw ltrb distances of shape |
required |
raw_points
|
Tensor
|
The same branch's raw point offsets, shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
A row-aligned triple: the decoder's |
Tensor
|
|
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> from lucid_yolo.decode.topk_e2e import TopKDecoder
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> cls_logits = torch.tensor([[[-5.0], [5.0]]])
>>> _, _, sets = decode_with_keypoints(
... TopKDecoder(k=3), cls_logits, torch.ones(1, 2, 4), torch.zeros(1, 2, 1, 2), points, strides
... )
>>> sets[0, :, 0] # anchor 1's centre, anchor 0's, then the padding row's origin
tensor([[12., 4.],
[ 4., 4.],
[ 0., 0.]])
gather_by_anchor
¶
Select each detection's per-anchor values by the anchor its own box came from.
The one gather every decode consumer pairs a detection with its anchor's values through — the mask coefficients of
Eq. 7 and the decoded point sets alike — so the padding rule below exists once. anchor_index comes from the
decoder that produced the detections, so row n of the result belongs to the anchor row n's box came from: a
row taken from a differently ranked selection would be a plausible mask or pose of the wrong object beside a
correct box.
Rows carrying :data:PAD_ANCHOR_INDEX are clamped to a valid gather position and then zeroed, so a padding row
never reads the last anchor's values. For coefficients that is an all-zero vector — a mask logit of exactly 0, a
probability of 0.5, and so an empty mask under the strictly-greater A37 threshold; for points it is the origin.
Those rows carry score 0 as well. On a batch with no padding row the clamp and the product by one leave every
gathered value bit-for-bit what a bare gather returns.
The values must already be in their final per-anchor form: points are decoded by
:func:~lucid_yolo.models.heads.keypoint.decode_keypoints over the whole anchor grid before this runs, because
that decode needs every anchor's centre and stride and offers no per-detection indexing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
per_anchor
|
Tensor
|
Dense per-anchor values of shape |
required |
anchor_index
|
Tensor
|
Source anchor per detection, shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
The selected values, shape |
Examples:
>>> import torch
>>> points = torch.arange(12.0).reshape(1, 3, 2, 2) # three anchors, two points each
>>> gathered = gather_by_anchor(points, torch.tensor([[2, -1]]))
>>> gathered.shape
torch.Size([1, 2, 2, 2])
>>> gathered[0, 0]
tensor([[ 8., 9.],
[10., 11.]])
>>> gathered[0, 1] # a padding row reads the origin, not the last anchor
tensor([[0., 0.],
[0., 0.]])
>>> gather_by_anchor(torch.ones(1, 3, 4), torch.tensor([[0, -1]])) # coefficients
tensor([[[1., 1., 1., 1.],
[0., 0., 0., 0.]]])
gather_keypoint_sets
¶
gather_keypoint_sets(raw_points: Tensor, anchor_index: Tensor, anchor_points: Tensor, strides: Tensor) -> Tensor
Decode one branch's dense point offsets and take each detection's point set.
Decode first, gather second, and the order is forced: :func:~lucid_yolo.models.heads.keypoint.decode_keypoints
composes the raw offsets with the whole anchor grid and has no per-detection indexing, so gathering first would
mean reconstructing each kept row's anchor and stride by hand. The gather is
:func:~lucid_yolo.decode.common.gather_by_anchor, so a padding row yields the origin rather than the last
anchor's pose.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_points
|
Tensor
|
Raw point offsets of shape |
required |
anchor_index
|
Tensor
|
Source anchor per detection, shape |
required |
anchor_points
|
Tensor
|
Anchor-centre |
required |
strides
|
Tensor
|
Per-anchor level stride of shape |
required |
Returns:
| Type | Description |
|---|---|
Tensor
|
Point coordinates in canvas pixels, shape |
Examples:
>>> import torch
>>> from lucid_yolo.assign.grid import make_anchor_points
>>> points, strides = make_anchor_points([(1, 2)], [8])
>>> raw_points = torch.zeros(1, 2, 1, 2) # one point per anchor, no offset
>>> gather_keypoint_sets(raw_points, torch.tensor([[1, -1]]), points, strides)
tensor([[[[12., 4.]],
<BLANKLINE>
[[ 0., 0.]]]])
points_to_letterboxed_original
¶
points_to_letterboxed_original(points: Tensor, orig_size: tuple[int, int], letterboxed_size: tuple[int, int], allow_upscale: bool = True) -> Tensor
Un-letterbox bare (x, y) points of one image back to original coordinates (A10).
The point-set twin of :func:to_letterboxed_original: the same exact inverse the box corners take, applied to
points with no score or class column beside them. Any leading shape is kept — (N, K, 2) point sets, a flat
(P, 2) list — because every point is folded into one flat list for a single
:meth:~lucid_yolo.data.letterbox.Letterbox.inverse_map call and the shape restored after, so no row can take a
different inverse from its neighbour. A batch whose images have different original sizes calls this once per image.
An empty input skips the inverse and comes back unchanged, so the trailing point axis survives an image with nothing in it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
points
|
Tensor
|
Letterboxed-canvas points of shape |
required |
orig_size
|
tuple[int, int]
|
Original image |
required |
letterboxed_size
|
tuple[int, int]
|
Letterboxed canvas |
required |
allow_upscale
|
bool
|
The letterbox |
True
|
Returns:
| Type | Description |
|---|---|
Tensor
|
Points of the input's shape in original-image coordinates. |
Examples:
>>> import torch
>>> # A 2x4 image letterboxed into a 4x4 canvas gains 1px top/bottom pads.
>>> sets = torch.tensor([[[0.0, 1.0], [4.0, 3.0]]]) # one instance, two points
>>> points_to_letterboxed_original(sets, orig_size=(2, 4), letterboxed_size=(4, 4))
tensor([[[0., 0.],
[4., 2.]]])
>>> points_to_letterboxed_original(torch.zeros(0, 17, 2), (2, 4), (4, 4)).shape
torch.Size([0, 17, 2])