Skip to content

🔎 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 .ckpt file.

required
use_ema bool

When True, overlay the :class:EMACallback shadow stored in the checkpoint onto the module's parameters and buffers.

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 hyper_parameters or state_dict mapping, no epoch or global_step, or -- under use_ema -- no num_updates beside the shadow; and if use_ema is requested and the checkpoint carries no EMA shadow, names the EMA callback in more than one callbacks entry, or carries a shadow that does not cover every floating-point parameter and buffer.

Examples:

>>> from pathlib import Path
>>> load_eval_module(Path("/nonexistent.ckpt"), use_ema=False)  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
FileNotFoundError: ...

pick_device

pick_device(requested: str) -> torch.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

"auto" or any string :class:torch.device accepts.

required

Returns:

Type Description
device

The chosen device.

Raises:

Type Description
ValueError

If requested names a backend this machine cannot run on.

Examples:

>>> pick_device("cpu")
device(type='cpu')
>>> pick_device("auto").type in available_devices()
True

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 detect module, as :func:~lucid_yolo.eval.checkpoint.load_eval_module returns it. Mutated in place — see the module docstring's Module ownership.

required
image Path | Tensor

Path to the image file to read, or an already-decoded (3, H, W) RGB tensor: uint8/uint16 codes, or floating point in [0, 1] (:func:~lucid_yolo.eval.annotations.letterbox_image_tensor).

required
img_size int

Letterbox side the model sees. Defaults to 640 (R1 sec. 4.4).

COCO_IMG_SIZE
decoder DecodePath

"e2e" for the suppression-free top-k path over the one-to-one branch, "nms" for the confidence-threshold plus class-wise suppression path over the dense branch.

'e2e'
conf_threshold float

Detections at or below this score are dropped. Defaults to :data:DEFAULT_CONF_THRESHOLD.

DEFAULT_CONF_THRESHOLD
device device | None

Device to run on. Defaults to CPU; the command resolves auto through :func:~lucid_yolo.eval.checkpoint.pick_device and passes the result.

None

Returns:

Type Description
Tensor

A CPU tensor of shape (N, 6) whose rows are the A9 tuple [x1, y1, x2, y2, score, class]

Tensor

in original-image pixels, score-descending. class is a contiguous class index (see the module docstring).

Raises:

Type Description
ValueError

If the module's task is not detect, naming the task it is; or if decoder, conf_threshold or img_size is outside its domain, per :func:_check_predict_arguments.

ValueError

If a tensor image is not (3, H, W), or is floating point outside [0, 1].

TypeError

If a tensor image holds signed integer or boolean values.

Examples:

>>> callable(predict_image)  # a real call needs a checkpoint and an image file
True

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 segment module, as :func:~lucid_yolo.eval.checkpoint.load_eval_module returns it. Mutated in place — see the module docstring's Module ownership.

required
image Path | Tensor

Path to the image file to read, or an already-decoded (3, H, W) RGB tensor: uint8/uint16 codes, or floating point in [0, 1] (:func:~lucid_yolo.eval.annotations.letterbox_image_tensor).

required
img_size int

Letterbox side the model sees. Defaults to 640 (R1 sec. 4.4).

COCO_IMG_SIZE
decoder DecodePath

"e2e" for the suppression-free top-k path over the one-to-one branch, "nms" for the confidence-threshold plus class-wise suppression path over the dense branch. The choice reaches the masks too: each path reads the mask coefficients of its own branch.

'e2e'
conf_threshold float

Detections at or below this score are dropped, with their masks. Defaults to :data:DEFAULT_CONF_THRESHOLD.

DEFAULT_CONF_THRESHOLD
device device | None

Device to run on. Defaults to CPU; the command resolves auto through :func:~lucid_yolo.eval.checkpoint.pick_device and passes the result.

None

Returns:

Name Type Description
A SegmentedPrediction

class:SegmentedPrediction holding the (N, 6) detections and their

SegmentedPrediction

(N, orig_height, orig_width) boolean masks, row-aligned.

Raises:

Type Description
ValueError

If the module's task is not segment, naming the task it is; if a module claiming that task has no mask coefficients to decode; or if decoder, conf_threshold or img_size is outside its domain, per :func:_check_predict_arguments.

ValueError

If a tensor image is not (3, H, W), or is floating point outside [0, 1].

TypeError

If a tensor image holds signed integer or boolean values.

Examples:

>>> callable(predict_segmentation)  # a real call needs a checkpoint and an image file
True

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 obb module, as :func:~lucid_yolo.eval.checkpoint.load_eval_module returns it. Mutated in place — see the module docstring's Module ownership.

required
image Path | Tensor

Path to the image file to read, or an already-decoded (3, H, W) RGB tensor: uint8/uint16 codes, or floating point in [0, 1] (:func:~lucid_yolo.eval.annotations.letterbox_image_tensor).

required
img_size int

Letterbox side the model sees. Defaults to :data:DEFAULT_ORIENTED_IMG_SIZE, the 1024 px the oriented tier trains at (R18 sec. 4), not the 640 the COCO tiers use.

DEFAULT_ORIENTED_IMG_SIZE
decoder DecodePath

"e2e" for the suppression-free rotated top-k path over the one-to-one branch, "nms" for the rotated-suppression path over the dense branch (:class:~lucid_yolo.decode.rotated_nms.RotatedNMSDecoder, WP-091b). Defaults to :data:ORIENTED_DECODE_PATH. The second is the oriented comparison column rather than a better answer: it suppresses by exact rotated overlap, so it costs a Python-level greedy loop the first path does not run at all.

ORIENTED_DECODE_PATH
conf_threshold float

Detections at or below this score are dropped. Defaults to :data:DEFAULT_CONF_THRESHOLD.

DEFAULT_CONF_THRESHOLD
device device | None

Device to run on. Defaults to CPU; the command resolves auto through :func:~lucid_yolo.eval.checkpoint.pick_device and passes the result.

None

Returns:

Type Description
Tensor

A CPU tensor of shape (N, 7) whose rows are the A45 tuple

Tensor

[cx, cy, w, h, theta, score, class] in original-image pixels,

Tensor

score-descending, canonical (w >= h, theta in [-pi/4, 3*pi/4), radians,

Tensor

measured from +x towards +y on the y-down image grid). class is a

Tensor

contiguous class index (see the module docstring).

Raises:

Type Description
ValueError

If the module's task is not obb, naming the task it is; if a module claiming the task emits no angles on the branch the selected decoder reads; or if decoder, conf_threshold or img_size is outside its domain, per :func:_check_predict_arguments.

ValueError

If a tensor image is not (3, H, W), or is floating point outside [0, 1].

TypeError

If a tensor image holds signed integer or boolean values.

Examples:

>>> callable(predict_oriented)  # a real call needs a checkpoint and an image file
True

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 keypoints module, as :func:~lucid_yolo.eval.checkpoint.load_eval_module returns it. Mutated in place — see the module docstring's Module ownership.

required
image Path | Tensor

Path to the image file to read, or an already-decoded (3, H, W) RGB tensor: uint8/uint16 codes, or floating point in [0, 1] (:func:~lucid_yolo.eval.annotations.letterbox_image_tensor).

required
img_size int

Letterbox side the model sees. Defaults to 640 (R1 sec. 4.4).

COCO_IMG_SIZE
decoder DecodePath

"e2e" for the suppression-free top-k path over the one-to-one branch, "nms" for the confidence-threshold plus class-wise suppression path over the dense branch. The choice reaches the points too: each path reads its own branch's point stem.

'e2e'
conf_threshold float

Detections at or below this score are dropped, with their points. Defaults to :data:DEFAULT_CONF_THRESHOLD.

DEFAULT_CONF_THRESHOLD
device device | None

Device to run on. Defaults to CPU; the command resolves auto through :func:~lucid_yolo.eval.checkpoint.pick_device and passes the result.

None

Returns:

Name Type Description
A KeypointPrediction

class:KeypointPrediction holding the (N, 6) detections and their

KeypointPrediction

(N, K, 2) point coordinates, row-aligned.

Raises:

Type Description
ValueError

If the module's task is not keypoints, naming the task it is; if a module claiming that task emits no points on the selected branch; or if decoder, conf_threshold or img_size is outside its domain, per :func:_check_predict_arguments.

ValueError

If a tensor image is not (3, H, W), or is floating point outside [0, 1].

TypeError

If a tensor image holds signed integer or boolean values.

Examples:

>>> callable(predict_keypoints)  # a real call needs a checkpoint and an image file
True

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:~lucid_yolo.eval.checkpoint.load_eval_module returns it. Moved to device in place.

required
data_root Path | None

COCO root holding val2017/ and annotations/. Optional when both ann_file and images_dir are given.

None
ann_file Path | None

Annotation JSON. Defaults to annotations/instances_val2017.json under data_root, or annotations/person_keypoints_val2017.json for a keypoint checkpoint.

None
images_dir Path | None

Image directory. Defaults to val2017 under data_root.

None
img_size int

Letterbox side, a positive multiple of every head stride. Defaults to 640.

COCO_IMG_SIZE
batch_size int

Images per forward pass. Defaults to 32.

_DEFAULT_BATCH_SIZE
device str

"auto", "cpu", "mps" or "cuda".

'auto'
limit int

Score only the first N images; 0 scores all.

0
masks bool

For a segmentation checkpoint, whether to score masks too.

True
eval_backend str

Box/mask scoring engine, "faster_coco_eval" or "hotcoco". The oks_ statistics always come from faster_coco_eval.

'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 detect, segment or keypoints; if a keypoint checkpoint's K is not COCO's 17; if limit, batch_size, img_size or eval_backend is outside its domain; if data_root is missing while an override is; or if device names a backend this machine does not have.

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]]

{"e2e": stats, "nms": stats}. Each stats holds the twelve COCO box statistics under their bare names (map is mAP50-95, then map_50, map_75 and the size and recall breakdowns), plus the twelve segm_ mask statistics for a segmentation checkpoint scored with masks, or the ten oks_ keypoint statistics for a keypoint checkpoint.

images int

How many images were scored, after any limit.

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 — pure top-k, nothing zeroed.

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

forward(cls_logits: Tensor, raw_ltrb: Tensor, anchor_points: Tensor, strides: Tensor) -> Tensor

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 (B, A, C).

required
raw_ltrb Tensor

Raw one-to-one ltrb distances of shape (B, A, 4) (order l, t, r, b), aligned with cls_logits on the anchor axis.

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2) in input pixels, as returned by :func:~lucid_yolo.assign.grid.make_anchor_points.

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

Detections of shape (B, k, 6) whose last axis is the A9 tuple [x1, y1, x2, y2, score, class],

Tensor

sorted by descending score with score-zero padding rows filling any shortfall below k.

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)
>>> TopKDecoder(k=5)(cls_logits, raw_ltrb, points, strides).shape
torch.Size([1, 5, 6])

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 (B, A, C).

required
raw_ltrb Tensor

Raw one-to-one ltrb distances of shape (B, A, 4), aligned with cls_logits on the anchor axis.

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2) in input pixels.

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

A pair (detections, anchor_indices): the (B, k, 6) A9 batch :meth:forward returns, and the

Tensor

(B, k) long tensor naming the anchor each row came from, with

tuple[Tensor, Tensor]

data:~lucid_yolo.decode.common.PAD_ANCHOR_INDEX on the 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]]])  # 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:~lucid_yolo.decode.common.MAX_DETECTIONS (A9).

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 (B, A, C).

required
raw_ltrb Tensor

Raw one-to-one ltrb distances of shape (B, A, 4).

required
raw_angles Tensor

Raw one-to-one angles in radians, shape (B, A, 1) or (B, A).

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2).

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

Detections of shape (B, k, 7) whose last axis is the A45 tuple

Tensor

[cx, cy, w, h, theta, score, class], sorted by descending score with score-zero padding rows

Tensor

filling any shortfall below k.

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 (B, A, C).

required
raw_ltrb Tensor

Raw one-to-one ltrb distances of shape (B, A, 4).

required
raw_angles Tensor

Raw one-to-one angles in radians, shape (B, A, 1) or (B, A).

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2).

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

A pair (detections, anchor_indices): the (B, k, 7) A45 batch

Tensor

meth:forward returns, and the (B, k) long tensor naming the anchor each

tuple[Tensor, Tensor]

row came from, with :data:~lucid_yolo.decode.common.PAD_ANCHOR_INDEX on the

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 0.001 (the permissive evaluation convention).

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 0.7.

DEFAULT_IOU_THRESHOLD
max_det int

Maximum detections kept per image; also the fixed output length. Defaults to 300 (R1 sec. 3.2.1).

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

forward(cls_logits: Tensor, raw_ltrb: Tensor, anchor_points: Tensor, strides: Tensor) -> Tensor

Decode raw dense outputs into fixed-size NMS-filtered detections.

Parameters:

Name Type Description Default
cls_logits Tensor

Raw dense class logits of shape (B, A, C).

required
raw_ltrb Tensor

Raw dense ltrb distances of shape (B, A, 4) (order l, t, r, b), aligned with cls_logits on the anchor axis.

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2) in input pixels, as returned by :func:~lucid_yolo.assign.grid.make_anchor_points.

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

Detections of shape (B, max_det, 6) whose last axis is the A9 tuple [x1, y1, x2, y2, score, class],

Tensor

sorted by descending score with score-zero padding rows filling any shortfall below max_det.

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)
>>> NMSDecoder(max_det=5)(cls_logits, raw_ltrb, points, strides).shape
torch.Size([1, 5, 6])

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 (B, A, C).

required
raw_ltrb Tensor

Raw dense ltrb distances of shape (B, A, 4), aligned with cls_logits on the anchor axis.

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2) in input pixels.

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

A pair (detections, anchor_indices): the (B, max_det, 6) A9 batch :meth:forward returns, and the

Tensor

(B, max_det) long tensor naming the anchor each row came from, with

tuple[Tensor, Tensor]

data:~lucid_yolo.decode.common.PAD_ANCHOR_INDEX on the 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]]])  # 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:

>>> empty, empty_anchors = decoder.decode_with_indices(
...     torch.zeros(0, 2, 1), torch.zeros(0, 2, 4), points, strides
... )
>>> empty.shape, empty_anchors.shape
(torch.Size([0, 3, 6]), torch.Size([0, 3]))

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 0.001 (the permissive evaluation convention).

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 (A61).

ROTATED_NMS_IOU_THRESHOLD
max_det int

Maximum detections kept per image; also the fixed output length. Defaults to 300 (R1 sec. 3.2.1, A9, A47).

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 (B, A, C).

required
raw_ltrb Tensor

Raw dense ltrb distances of shape (B, A, 4) (order l, t, r, b), aligned with cls_logits on the anchor axis.

required
angles Tensor

Raw dense orientation angles in radians, shape (B, A, 1) or (B, A), as R1 Eq. 13 emits them — any real number, canonicalized by the decode rather than by the head (A23).

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2) in input pixels, as returned by :func:~lucid_yolo.assign.grid.make_anchor_points.

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

Detections of shape (B, max_det, 7) whose last axis is the A45 tuple

Tensor

[cx, cy, w, h, theta, score, class], canonical (w >= h, theta in [-pi/4, 3*pi/4)), sorted

Tensor

by descending score, with score-zero padding rows filling any shortfall below max_det.

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:~lucid_yolo.decode.topk_e2e.TopKDecoder or :class:~lucid_yolo.decode.nms_path.NMSDecoder.

required
cls_logits Tensor

Raw class logits of shape (B, A, C).

required
raw_ltrb Tensor

Raw ltrb distances of shape (B, A, 4).

required
coefficients Tensor

The same branch's mask coefficients, shape (B, A, M).

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2).

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

A row-aligned triple: the decoder's (B, N, 6) detections, its (B, N) anchor indices, and the

Tensor

(B, N, M) gathered coefficients, all-zero on padding rows.

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:~lucid_yolo.decode.topk_e2e.TopKDecoder or :class:~lucid_yolo.decode.nms_path.NMSDecoder.

required
cls_logits Tensor

Raw class logits of shape (B, A, C).

required
raw_ltrb Tensor

Raw ltrb distances of shape (B, A, 4).

required
raw_points Tensor

The same branch's raw point offsets, shape (B, A, K, 2).

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2).

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

A row-aligned triple: the decoder's (B, N, 6) detections, its (B, N) anchor indices, and the

Tensor

(B, N, K, 2) point sets in canvas pixels, zero on padding rows.

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

gather_by_anchor(per_anchor: Tensor, anchor_index: Tensor) -> Tensor

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 (B, A, *trailing) — mask coefficients (B, A, M), decoded point sets (B, A, K, 2).

required
anchor_index Tensor

Source anchor per detection, shape (B, N), as a decoder's decode_with_indices reports it.

required

Returns:

Type Description
Tensor

The selected values, shape (B, N, *trailing), padding rows all zero.

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 (B, A, K, 2).

required
anchor_index Tensor

Source anchor per detection, shape (B, N).

required
anchor_points Tensor

Anchor-centre (x, y) coordinates of shape (A, 2).

required
strides Tensor

Per-anchor level stride of shape (A,).

required

Returns:

Type Description
Tensor

Point coordinates in canvas pixels, shape (B, N, K, 2), padding rows zero.

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 (..., 2).

required
orig_size tuple[int, int]

Original image (height, width).

required
letterboxed_size tuple[int, int]

Letterboxed canvas (height, width) the points live in.

required
allow_upscale bool

The letterbox allow_upscale setting used at resize time; must match so the inverse recovers the exact geometry. Defaults to True.

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])