Skip to content

Validation & analytics

Score a trained detector with the standard COCO metrics, and — optionally — render the diagnostic plots you look at after the numbers to understand where the model fails.

  • DFINE.val — the one-call entry point (COCO metrics, optional plots).
  • Analytics artifacts — confusion matrix, P/R/F1-vs-confidence curves, per-class AP, and a worst-predictions gallery.
  • API reference — the underlying evaluate / metric classes.

Running validation

from dfine import DFINE

model = DFINE.from_pretrained("dfine-l")

# A COCO dataset root: val/ + annotations/instances_val.json (a stock MS-COCO
# val2017/ layout is auto-detected too).
metrics = model.val(data="coco/")
print(metrics["AP"])  # primary mAP@[.50:.95]
print(metrics["AP50"])  # mAP@0.50

val returns the 12 standard COCO metrics keyed by name (COCO_STAT_NAMES):

Key Meaning
AP mAP averaged over IoU 0.50–0.95 (the headline number)
AP50, AP75 mAP at IoU 0.50 / 0.75
AP_small, AP_medium, AP_large mAP by GT object area
AR_1, AR_10, AR_100 average recall given 1 / 10 / 100 detections per image
AR_small, AR_medium, AR_large average recall by object area

Label spaces must line up

The single most common validation footgun: predicted class ids must match the category_id values in your ground-truth annotations.

Stock MS-COCO uses sparse ids (1..90)

pydfine predicts contiguous 0..N-1 labels by default. To score against stock MS-COCO ground truth, build the model with remap_mscoco_category=True so the postprocessor emits the sparse ids the annotations use:

model = DFINE.from_pretrained("dfine-l", remap_mscoco_category=True)
metrics = model.val(data="coco/", remap_mscoco_category=True)

Datasets produced by yolo_to_coco are already 0-indexed contiguous, so the default remap_mscoco_category=False is correct for them.

Bring your own loader

from dfine.train.dataset import build_coco_val_dataloader

loader = build_coco_val_dataloader("coco/", cfg=model.config, batch_size=8)
metrics = model.val(val_loader=loader)  # loader.dataset must carry the GT `.coco`

From the CLI

dfine val dfine-l --data coco/
dfine val dfine-l --data coco/ --plots --output-dir runs/val   # + analytics plots

Analytics artifacts

Pass plots=True to also write a diagnostic bundle under output_dir (default runs/val). This needs matplotlib (the [train] extra) and assumes contiguous labels (remap_mscoco_category=False).

metrics = model.val(data="coco/", plots=True, output_dir="runs/val")
runs/val/
├── confusion_matrix.png    # predicted-vs-true grid (which classes get confused)
├── pr_curve.png            # per-class precision–recall at IoU 0.50
├── f1_curve.png            # F1 vs confidence  (its peak → best operating point)
├── p_curve.png             # precision vs confidence
├── r_curve.png             # recall vs confidence
└── worst/                  # highest-error frames, GT green / pred red
    ├── 00_err7_000042.jpg
    └── ...

The per-class AP table and the recommended confidence are also logged:

per-class AP  person 0.72  car 0.65  dog 0.51  ...
Best confidence: 0.31  (mean F1 0.68)
Analytics saved to runs/val

Consistent IoU matching (0.5)

All three custom accumulators — ConfusionMatrix, PRCurveMetrics, and WorstPredictions — match a detection to a ground-truth box at IoU ≥ 0.5 (a common single-threshold TP definition). This is a coarser, more interpretable view than COCO's IoU 0.50–0.95 sweep, and it is deliberately the same across every plot so the artifacts agree with each other. The numeric COCO metrics above are independent — they come from the COCO evaluator, not these classes.

Reading each plot

Plot What it answers How to read it
Confusion matrix Which classes does the model mix up? Column-normalized (per true class). Bright off-diagonal cells = systematic confusion; the last row/column is background (false negatives / false positives).
F1–confidence What conf should I deploy at? The black mean curve peaks at the best trade-off — that x-value is Best confidence.
P–confidence How clean are detections as I raise the bar? Precision rises with confidence; find where it saturates.
R–confidence How much do I miss as I raise the bar? Recall falls with confidence; the crossover with precision is the F1 peak.
PR curve Per-class quality at IoU 0.50 Area under each curve ≈ that class's AP50; the mean line is mAP@.5.
worst/ gallery Where are the failures / label errors? Frames with the most FP+FN, GT in green, predictions in red — spot mislabeled data and hard cases fast.

Pick a deployment confidence

The F1-vs-confidence sweep gives a data-driven answer to "what conf do I pass to predict?" — no eyeballing:

from dfine.train.dataset import build_coco_val_dataloader
from dfine.train.evaluator import evaluate
from dfine.train.metrics import PRCurveMetrics

loader = build_coco_val_dataloader("coco/", cfg=model.config)

# evaluate(plots=True) logs "Best confidence: X" for you; to compute it directly:
prm = PRCurveMetrics(num_classes=model.config.num_classes)  # matches at IoU 0.5
# ... feed prm.process_batch(det_boxes, det_scores, det_classes, gt_boxes, gt_classes) ...
best_conf, mean_f1 = prm.best_confidence()
results = model.predict("street.jpg", conf=best_conf)

Validate every epoch during training

DFINE.train auto-wires COCO validation whenever a val loader is present (built from data=, or passed explicitly) — the AP curve is logged and streamed to TensorBoard, and best.pth is saved on each improvement:

model.train(data="coco/", epochs=72)  # val/ split validated each epoch

To also render the full analytics bundle every epoch, pass val_plots=True — each epoch's confusion matrix, P/R/F1 curves, per-class AP, and worst-predictions gallery are written to output_dir/val/epoch{N}/ (kept side by side, not overwritten):

model.train(data="coco/", epochs=72, val_plots=True)
# runs/train/val/epoch0/…, runs/train/val/epoch1/…  (same artifacts as val(plots=True))

Note

val_plots needs matplotlib (the [train] extra) and contiguous labels — it is skipped with a warning when remap_mscoco_category=True or for segment/sem_seg tasks. It is off by default since it adds per-epoch compute and disk.

To customize, pass your own hook — see coco_val_fn (which itself takes plots=, plots_dir=, names=):

from dfine.train.evaluator import coco_val_fn

val_fn = coco_val_fn(model.postprocessor, model.device)
# trainer.fit(train_loader, val_loader=loader, val_fn=val_fn)

API reference

DFINE.val

val

val(data: str | PathLike | None = None, *, val_loader=None, batch_size: int = 4, num_workers: int = 4, remap_mscoco_category: bool | None = None, plots: bool = False, output_dir: str = 'runs/val') -> dict[str, float]

Evaluate the model on a COCO val set and return the metrics dict.

Provide the data one of two ways (mutually exclusive):

  • data="path/to/coco" — a COCO root; the val loader is built from val/ + annotations/instances_val.json for you (a stock MS-COCO val2017/ layout is auto-detected too).
  • val_loader=... — a ready loader from build_coco_dataloader (its dataset must carry the ground-truth .coco).

Returns the 12 standard COCO metrics keyed by name (AP is the primary mAP@[.50:.95]); see :data:~dfine.train.evaluator.COCO_STAT_NAMES. For stock MS-COCO ground truth (sparse category ids), build the model with remap_mscoco_category=True so predicted labels match the annotations.

With plots=True also writes a confusion matrix + precision–recall curves and logs the per-class AP table under output_dir (default runs/val); needs matplotlib (the [train] extra). The confusion matrix assumes contiguous labels, so plots is ignored (with a warning) when remap_mscoco_category=True.

Source code in dfine/model.py
def val(
    self,
    data: str | os.PathLike | None = None,
    *,
    val_loader=None,
    batch_size: int = 4,
    num_workers: int = 4,
    remap_mscoco_category: bool | None = None,
    plots: bool = False,
    output_dir: str = "runs/val",
) -> dict[str, float]:
    """Evaluate the model on a COCO val set and return the metrics dict.

    Provide the data one of two ways (mutually exclusive):

    * ``data="path/to/coco"`` — a COCO root; the val loader is built from
      ``val/`` + ``annotations/instances_val.json`` for you (a stock MS-COCO
      ``val2017/`` layout is auto-detected too).
    * ``val_loader=...`` — a ready loader from ``build_coco_dataloader`` (its
      dataset must carry the ground-truth ``.coco``).

    Returns the 12 standard COCO metrics keyed by name (``AP`` is the primary
    mAP@[.50:.95]); see :data:`~dfine.train.evaluator.COCO_STAT_NAMES`. For stock
    MS-COCO ground truth (sparse category ids), build the model with
    ``remap_mscoco_category=True`` so predicted labels match the annotations.

    With ``plots=True`` also writes a confusion matrix + precision–recall curves and
    logs the per-class AP table under ``output_dir`` (default ``runs/val``); needs
    matplotlib (the ``[train]`` extra). The confusion matrix assumes contiguous labels,
    so ``plots`` is ignored (with a warning) when ``remap_mscoco_category=True``.
    """
    if data is None and val_loader is None:
        raise ValueError("Provide validation data via `data=` or `val_loader=`.")
    if data is not None and val_loader is not None:
        raise ValueError("Pass either `data=` or `val_loader=`, not both.")
    remap_mscoco_category = self._resolve_coco_remap(remap_mscoco_category)
    if data is not None:
        from .train.dataset import build_coco_val_dataloader

        val_loader = build_coco_val_dataloader(
            data,
            cfg=self.config,
            batch_size=batch_size,
            num_workers=num_workers,
            remap_mscoco_category=remap_mscoco_category,
        )

    if plots and remap_mscoco_category:
        # The analytics (confusion matrix / per-class AP) assume contiguous 0..N-1 labels;
        # with the sparse MS-COCO remap the GT label space disagrees, so the plots would be
        # silently wrong. Mirror DFINE.train's guard and skip them (COCO metrics unaffected).
        LOGGER.warning(
            "val(plots=True) needs contiguous labels — plots disabled because "
            "remap_mscoco_category=True (COCO metrics are unaffected)."
        )
        plots = False

    from .train.evaluator import evaluate

    return evaluate(
        self.model,
        self.postprocessor,
        val_loader,
        self.device,
        plots=plots,
        output_dir=output_dir,
        names=self.names,
    )

evaluate

dfine.train.evaluator.evaluate

evaluate(model: Module, postprocessor: Module, data_loader: Iterable, device: device, *, iou_type: str = 'bbox', plots: bool = False, output_dir: str | None = None, names: dict[int, str] | None = None) -> dict[str, float]

Evaluate model over data_loader and return COCO metrics.

model runs in eval mode (restored afterwards); each batch is decoded by postprocessor to original-scale xyxy boxes, then scored against the loader's ground truth. Returns a dict keyed by :data:COCO_STAT_NAMES (AP is the primary mAP@[.50:.95]).

With plots=True also writes confusion_matrix.png + pr_curve.png under output_dir (default runs/val) and logs the per-class AP table — see :mod:dfine.train.metrics. names maps class id → label for the plots. The confusion matrix assumes contiguous labels (remap_mscoco_category=False).

Source code in dfine/train/evaluator.py
@torch.no_grad()
def evaluate(
    model: nn.Module,
    postprocessor: nn.Module,
    data_loader: Iterable,
    device: torch.device,
    *,
    iou_type: str = "bbox",
    plots: bool = False,
    output_dir: str | None = None,
    names: dict[int, str] | None = None,
) -> dict[str, float]:
    """Evaluate ``model`` over ``data_loader`` and return COCO metrics.

    ``model`` runs in eval mode (restored afterwards); each batch is decoded by
    ``postprocessor`` to original-scale ``xyxy`` boxes, then scored against the loader's
    ground truth. Returns a dict keyed by :data:`COCO_STAT_NAMES` (``AP`` is the primary
    mAP@[.50:.95]).

    With ``plots=True`` also writes ``confusion_matrix.png`` + ``pr_curve.png`` under
    ``output_dir`` (default ``runs/val``) and logs the per-class AP table — see
    :mod:`dfine.train.metrics`. ``names`` maps class id → label for the plots. The
    confusion matrix assumes contiguous labels (``remap_mscoco_category=False``).
    """
    from faster_coco_eval.utils.pytorch import FasterCocoEvaluator

    evaluator = FasterCocoEvaluator(_coco_gt(data_loader), [iou_type])

    # Analytics plots (confusion matrix / PR curves / worst frames) accumulate locally per
    # process, so under multi-GPU each rank sees only its data shard AND every rank would race
    # writing the same PNGs. Disable them there; the COCO metrics below are still gathered
    # across ranks by FasterCocoEvaluator and remain correct.
    from .distributed import get_world_size

    if plots and get_world_size() > 1:
        LOGGER.warning(
            "val analytics plots are disabled under multi-GPU (per-rank shards would be partial "
            "and ranks would race writing the same files); COCO metrics are unaffected."
        )
        plots = False

    cm = prm = worst = None
    if plots:
        from .metrics import ConfusionMatrix, PRCurveMetrics, WorstPredictions

        cat_ids = [int(c) for c in evaluator.coco_eval[iou_type].params.catIds]
        nc = (max(names) + 1) if names else (max(cat_ids, default=-1) + 1)
        cm, prm, worst = ConfusionMatrix(nc), PRCurveMetrics(nc), WorstPredictions()

    was_training = model.training
    model.eval()
    try:
        for samples, targets in data_loader:
            samples = samples.to(device)
            orig_sizes = torch.stack([t["orig_size"].to(device) for t in targets], dim=0)
            outputs = model(samples)
            results = postprocessor(outputs, orig_sizes)
            evaluator.update({int(t["image_id"].item()): r for t, r in zip(targets, results)})
            if cm is not None:
                _update_analytics(cm, prm, worst, results, targets)
    finally:
        if was_training:
            model.train()

    evaluator.synchronize_between_processes()
    evaluator.accumulate()
    evaluator.summarize()

    stats = evaluator.coco_eval[iou_type].stats.tolist()
    metrics = {name: float(v) for name, v in zip(COCO_STAT_NAMES, stats)}
    LOGGER.info(f"{rule(f'eval · {iou_type}', 'cyan')}  {metrics_line(metrics)}")

    if cm is not None:
        from .metrics import save_val_analytics

        save_val_analytics(
            evaluator.coco_eval[iou_type],
            cm,
            output_dir or "runs/val",
            names,
            prm=prm,
            worst=worst,
        )
    return metrics

dfine.train.evaluator.coco_val_fn

coco_val_fn(postprocessor: Module, device: device, *, iou_type: str = 'bbox', plots: bool = False, plots_dir: str | Path | None = None, names: dict[int, str] | None = None) -> Callable[[nn.Module, Iterable], dict[str, float]]

Build a (module, loader) -> metrics closure for Trainer.fit(val_fn=…).

Captures the postprocessor/device so the trainer can score the (EMA) module each epoch: trainer.fit(train_loader, val_loader=…, val_fn=coco_val_fn(pp, dev)).

With plots=True the analytics bundle (confusion matrix, P/R/F1 curves, per-class AP, worst-predictions gallery) is also written every epoch under plots_dir/epoch{N}/ (plots_dir defaults to runs/train/val), where N counts validation calls from 0 — so each epoch's plots are kept side by side rather than overwritten. names maps class id → label for the plots. The analytics assume contiguous labels (remap_mscoco_category=False).

Source code in dfine/train/evaluator.py
def coco_val_fn(
    postprocessor: nn.Module,
    device: torch.device,
    *,
    iou_type: str = "bbox",
    plots: bool = False,
    plots_dir: str | Path | None = None,
    names: dict[int, str] | None = None,
) -> Callable[[nn.Module, Iterable], dict[str, float]]:
    """Build a ``(module, loader) -> metrics`` closure for ``Trainer.fit(val_fn=…)``.

    Captures the ``postprocessor``/``device`` so the trainer can score the (EMA) module
    each epoch: ``trainer.fit(train_loader, val_loader=…, val_fn=coco_val_fn(pp, dev))``.

    With ``plots=True`` the analytics bundle (confusion matrix, P/R/F1 curves, per-class AP,
    worst-predictions gallery) is also written **every epoch** under
    ``plots_dir/epoch{N}/`` (``plots_dir`` defaults to ``runs/train/val``), where ``N``
    counts validation calls from 0 — so each epoch's plots are kept side by side rather than
    overwritten. ``names`` maps class id → label for the plots. The analytics assume
    contiguous labels (``remap_mscoco_category=False``).
    """
    base = Path(plots_dir) if plots_dir is not None else Path("runs/train") / "val"
    state = {"call": 0}

    def _val_fn(module: nn.Module, loader: Iterable) -> dict[str, float]:
        out_dir = str(base / f"epoch{state['call']}") if plots else None
        metrics = evaluate(
            module,
            postprocessor,
            loader,
            device,
            iou_type=iou_type,
            plots=plots,
            output_dir=out_dir,
            names=names,
        )
        state["call"] += 1
        return metrics

    return _val_fn

Metrics

dfine.train.metrics.ConfusionMatrix

ConfusionMatrix(num_classes: int, conf: float = 0.25, iou_thresh: float = 0.5)

Predicted-vs-true detection counts, matched by IoU (ultralytics-style).

matrix[p, t] counts predictions of class p matched to a true box of class t; index nc (the extra last row/col) is backgroundmatrix[nc, t] are missed GT (false negatives), matrix[p, nc] are spurious detections (false positives). Feed one image at a time with :meth:process_batch.

Source code in dfine/train/metrics.py
def __init__(self, num_classes: int, conf: float = 0.25, iou_thresh: float = 0.5):
    self.nc = int(num_classes)
    self.conf = conf
    self.iou_thresh = iou_thresh
    self.matrix = np.zeros((self.nc + 1, self.nc + 1), dtype=np.int64)

process_batch

process_batch(det_boxes: ndarray, det_scores: ndarray, det_classes: ndarray, gt_boxes: ndarray, gt_classes: ndarray) -> None

Accumulate one image's detections (xyxy/score/class) against its GT.

Source code in dfine/train/metrics.py
def process_batch(
    self,
    det_boxes: np.ndarray,
    det_scores: np.ndarray,
    det_classes: np.ndarray,
    gt_boxes: np.ndarray,
    gt_classes: np.ndarray,
) -> None:
    """Accumulate one image's detections (``xyxy``/score/class) against its GT."""
    bg = self.nc
    det_boxes = np.asarray(det_boxes, dtype=np.float64).reshape(-1, 4)
    det_scores = np.asarray(det_scores, dtype=np.float64).reshape(-1)
    det_classes = np.asarray(det_classes).reshape(-1).astype(int)
    gt_classes = np.asarray(gt_classes).reshape(-1).astype(int)

    keep = (det_scores >= self.conf) & (det_classes >= 0) & (det_classes < self.nc)
    db, dc = det_boxes[keep], det_classes[keep]
    gmask = (gt_classes >= 0) & (gt_classes < self.nc)
    gb, gc = np.asarray(gt_boxes, dtype=np.float64).reshape(-1, 4)[gmask], gt_classes[gmask]

    if len(gc) == 0:  # every kept detection is a false positive
        for c in dc:
            self.matrix[c, bg] += 1
        return
    if len(db) == 0:  # every GT is a miss
        for c in gc:
            self.matrix[bg, c] += 1
        return

    iou = box_iou(db, gb)
    matched_det: set[int] = set()
    matched_gt: set[int] = set()
    # Greedy highest-IoU-first assignment, each det/GT used once (above the threshold).
    pairs = [
        (iou[i, j], i, j)
        for i in range(len(db))
        for j in range(len(gb))
        if iou[i, j] >= self.iou_thresh
    ]
    for _, i, j in sorted(pairs, key=lambda x: x[0], reverse=True):
        if i in matched_det or j in matched_gt:
            continue
        matched_det.add(i)
        matched_gt.add(j)
        self.matrix[dc[i], gc[j]] += 1
    for i in range(len(db)):
        if i not in matched_det:
            self.matrix[dc[i], bg] += 1  # false positive
    for j in range(len(gb)):
        if j not in matched_gt:
            self.matrix[bg, gc[j]] += 1  # false negative (missed GT)

plot

plot(save_path: str | Path, names: dict[int, str] | None = None) -> Path

Save a column-normalized heatmap (per-true-class recall view) to save_path.

Source code in dfine/train/metrics.py
def plot(self, save_path: str | Path, names: dict[int, str] | None = None) -> Path:
    """Save a column-normalized heatmap (per-true-class recall view) to ``save_path``."""
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    labels = [(names.get(i, str(i)) if names else str(i)) for i in range(self.nc)]
    labels = labels + ["background"]
    col_sums = self.matrix.sum(axis=0, keepdims=True)
    norm = np.divide(
        self.matrix, col_sums, out=np.zeros_like(self.matrix, float), where=col_sums > 0
    )

    fig, ax = plt.subplots(figsize=(max(6, self.nc * 0.6),) * 2)
    im = ax.imshow(norm, cmap="Blues", vmin=0, vmax=1)
    ax.set_xlabel("True")
    ax.set_ylabel("Predicted")
    ax.set_xticks(range(self.nc + 1), labels, rotation=90, fontsize=7)
    ax.set_yticks(range(self.nc + 1), labels, fontsize=7)
    fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
    ax.set_title("Confusion matrix (normalized)")
    fig.tight_layout()
    save_path = Path(save_path)
    save_path.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(save_path, dpi=150)
    plt.close(fig)
    return save_path

dfine.train.metrics.PRCurveMetrics

PRCurveMetrics(num_classes: int, iou_thresh: float = 0.5)

Accumulate (score, is_TP) per class to build P/R/F1-vs-confidence curves.

Each detection is matched (per class, highest-score-first) to a same-class GT box at iou_thresh — matched → true positive, else false positive. After the val pass, :meth:curves sweeps the confidence axis to give per-class + mean precision, recall and F1, and :meth:best_confidence returns the threshold that maximizes mean F1 — the practical "what conf should I use?" answer.

Source code in dfine/train/metrics.py
def __init__(self, num_classes: int, iou_thresh: float = 0.5):
    self.nc = int(num_classes)
    self.iou_thresh = iou_thresh
    self._tp: list[bool] = []
    self._conf: list[float] = []
    self._cls: list[int] = []
    self.n_gt = np.zeros(self.nc, dtype=np.int64)

process_batch

process_batch(det_boxes, det_scores, det_classes, gt_boxes, gt_classes) -> None

Match one image's detections to its GT (per class) and record (score, TP).

Source code in dfine/train/metrics.py
def process_batch(self, det_boxes, det_scores, det_classes, gt_boxes, gt_classes) -> None:
    """Match one image's detections to its GT (per class) and record ``(score, TP)``."""
    dc = np.asarray(det_classes).reshape(-1).astype(int)
    ds = np.asarray(det_scores, dtype=np.float64).reshape(-1)
    db = np.asarray(det_boxes, dtype=np.float64).reshape(-1, 4)
    gc = np.asarray(gt_classes).reshape(-1).astype(int)
    gb = np.asarray(gt_boxes, dtype=np.float64).reshape(-1, 4)

    for c in gc:
        if 0 <= c < self.nc:
            self.n_gt[c] += 1
    if len(db) == 0:
        return

    valid = (dc >= 0) & (dc < self.nc)
    db, ds, dc = db[valid], ds[valid], dc[valid]
    iou = box_iou(db, gb) if len(gb) else np.zeros((len(db), 0))
    matched: set[int] = set()
    for idx in np.argsort(-ds):
        c = int(dc[idx])
        tp = False
        if len(gb):
            cand = [
                j
                for j in range(len(gb))
                if gc[j] == c and j not in matched and iou[idx, j] >= self.iou_thresh
            ]
            if cand:
                matched.add(max(cand, key=lambda j: iou[idx, j]))
                tp = True
        self._tp.append(tp)
        self._conf.append(float(ds[idx]))
        self._cls.append(c)

curves

curves(n: int = 1000)

Return (x, precision, recall, f1, classes) over an n-point confidence grid.

x is the confidence axis [n]; precision/recall/f1 are [len(classes), n] for the classes that have any GT.

Source code in dfine/train/metrics.py
def curves(self, n: int = 1000):
    """Return ``(x, precision, recall, f1, classes)`` over an ``n``-point confidence grid.

    ``x`` is the confidence axis ``[n]``; ``precision``/``recall``/``f1`` are
    ``[len(classes), n]`` for the classes that have any GT.
    """
    eps = 1e-16
    x = np.linspace(0, 1, n)
    tp = np.asarray(self._tp, dtype=bool)
    conf = np.asarray(self._conf, dtype=np.float64)
    cls = np.asarray(self._cls, dtype=int)
    classes = [c for c in range(self.nc) if self.n_gt[c] > 0]

    p = np.ones((len(classes), n))
    r = np.zeros((len(classes), n))
    for ci, c in enumerate(classes):
        m = cls == c
        if not m.any():
            continue
        order = np.argsort(-conf[m])
        csorted = conf[m][order]
        tpc = tp[m][order].cumsum()
        fpc = (~tp[m][order]).cumsum()
        recall = tpc / (self.n_gt[c] + eps)
        precision = tpc / (tpc + fpc + eps)
        # curves are functions of the confidence threshold (conf sorted descending)
        r[ci] = np.interp(-x, -csorted, recall, left=0)
        p[ci] = np.interp(-x, -csorted, precision, left=1)
    f1 = 2 * p * r / (p + r + eps)
    return x, p, r, f1, classes

best_confidence

best_confidence() -> tuple[float, float]

(conf, mean_F1) at the confidence maximizing mean F1 ((0.0, 0.0) if empty).

Source code in dfine/train/metrics.py
def best_confidence(self) -> tuple[float, float]:
    """``(conf, mean_F1)`` at the confidence maximizing mean F1 (``(0.0, 0.0)`` if empty)."""
    x, _p, _r, f1, classes = self.curves()
    if not classes:
        return 0.0, 0.0
    mean_f1 = f1.mean(axis=0)
    i = int(mean_f1.argmax())
    return float(x[i]), float(mean_f1[i])

dfine.train.metrics.WorstPredictions

WorstPredictions(conf: float = 0.25, iou_thresh: float = 0.5, top_k: int = 16)

Keep the top_k images with the most errors (FP + FN) for visual review.

add counts a frame's mistakes (class-aware IoU matching at iou_thresh, detections filtered at conf): unmatched GT = false negatives, unmatched detections = false positives, a wrong-class match counts as both. Only a bounded top-k heap is kept, so memory stays flat over the val set. save renders each keeper — green = ground truth, red = prediction — to output_dir/worst/ for spotting label errors and failure modes.

Source code in dfine/train/metrics.py
def __init__(self, conf: float = 0.25, iou_thresh: float = 0.5, top_k: int = 16):
    self.conf = conf
    self.iou_thresh = iou_thresh
    self.top_k = top_k
    self._heap: list = []  # min-heap of (n_errors, seq, record)
    self._seq = 0

add

add(image_path, det_boxes, det_scores, det_classes, gt_boxes, gt_classes) -> None

Consider one image; keep it only if it is among the top_k worst so far.

Source code in dfine/train/metrics.py
def add(self, image_path, det_boxes, det_scores, det_classes, gt_boxes, gt_classes) -> None:
    """Consider one image; keep it only if it is among the ``top_k`` worst so far."""
    import heapq

    if not image_path:
        return
    db = np.asarray(det_boxes, dtype=np.float64).reshape(-1, 4)
    ds = np.asarray(det_scores, dtype=np.float64).reshape(-1)
    dc = np.asarray(det_classes).reshape(-1).astype(int)
    keep = ds >= self.conf
    db, ds, dc = db[keep], ds[keep], dc[keep]
    gb = np.asarray(gt_boxes, dtype=np.float64).reshape(-1, 4)
    gc = np.asarray(gt_classes).reshape(-1).astype(int)

    fp, fn = self._error_count(db, dc, gb, gc)
    n_err = fp + fn
    if n_err == 0:
        return
    self._seq += 1
    record = (str(image_path), n_err, fp, fn, db, ds, dc, gb)
    item = (n_err, self._seq, record)
    if len(self._heap) < self.top_k:
        heapq.heappush(self._heap, item)
    elif n_err > self._heap[0][0]:
        heapq.heapreplace(self._heap, item)

save

save(output_dir, names=None) -> list[str]

Render the kept worst frames (GT green, prediction red) to output_dir/worst/.

Source code in dfine/train/metrics.py
def save(self, output_dir, names=None) -> list[str]:
    """Render the kept worst frames (GT green, prediction red) to ``output_dir/worst/``."""
    from PIL import Image, ImageDraw

    worst_dir = Path(output_dir) / "worst"
    records = [rec for _, _, rec in sorted(self._heap, key=lambda x: x[0], reverse=True)]
    if not records:
        return []
    worst_dir.mkdir(parents=True, exist_ok=True)

    out: list[str] = []
    for rank, (path, n_err, fp, fn, db, ds, dc, gb) in enumerate(records):
        try:
            img = Image.open(path).convert("RGB")
        except Exception:  # pragma: no cover - unreadable source is skipped
            continue
        draw = ImageDraw.Draw(img)
        for box in gb:
            draw.rectangle([float(v) for v in box], outline=(0, 200, 0), width=2)
        for box, s, c in zip(db, ds, dc):
            name = names.get(int(c), str(int(c))) if names else str(int(c))
            draw.rectangle([float(v) for v in box], outline=(255, 40, 40), width=2)
            draw.text(
                (float(box[0]) + 2, float(box[1]) + 2), f"{name} {s:.2f}", fill=(255, 40, 40)
            )
        draw.text((4, 4), f"FP={fp}  FN={fn}   GT=green pred=red", fill=(255, 255, 0))
        dst = worst_dir / f"{rank:02d}_err{n_err}_{Path(path).stem}.jpg"
        img.save(dst)
        out.append(str(dst))
    return out

dfine.train.metrics.per_class_ap

per_class_ap(coco_eval, names: dict[int, str] | None = None) -> dict[str, float]

Per-class AP@[.50:.95] from a COCO evaluator's precision tensor (area=all, maxDet=100).

Source code in dfine/train/metrics.py
def per_class_ap(coco_eval, names: dict[int, str] | None = None) -> dict[str, float]:
    """Per-class AP@[.50:.95] from a COCO evaluator's precision tensor (area=all, maxDet=100)."""
    precision = coco_eval.eval["precision"]  # [T, R, K, A, M]
    cat_ids = list(coco_eval.params.catIds)
    out: dict[str, float] = {}
    for k, cat_id in enumerate(cat_ids):
        pr = precision[:, :, k, 0, -1]
        valid = pr[pr > -1]
        ap = float(valid.mean()) if valid.size else float("nan")
        out[names.get(cat_id, str(cat_id)) if names else str(cat_id)] = ap
    return out

dfine.train.metrics.box_iou

box_iou(a: ndarray, b: ndarray) -> np.ndarray

IoU between two sets of xyxy boxes → [len(a), len(b)] (0 where either is empty).

Source code in dfine/train/metrics.py
def box_iou(a: np.ndarray, b: np.ndarray) -> np.ndarray:
    """IoU between two sets of ``xyxy`` boxes → ``[len(a), len(b)]`` (0 where either is empty)."""
    a = np.asarray(a, dtype=np.float64).reshape(-1, 4)
    b = np.asarray(b, dtype=np.float64).reshape(-1, 4)
    if len(a) == 0 or len(b) == 0:
        return np.zeros((len(a), len(b)), dtype=np.float64)
    area_a = (a[:, 2] - a[:, 0]).clip(0) * (a[:, 3] - a[:, 1]).clip(0)
    area_b = (b[:, 2] - b[:, 0]).clip(0) * (b[:, 3] - b[:, 1]).clip(0)
    lt = np.maximum(a[:, None, :2], b[None, :, :2])
    rb = np.minimum(a[:, None, 2:], b[None, :, 2:])
    wh = (rb - lt).clip(0)
    inter = wh[..., 0] * wh[..., 1]
    union = area_a[:, None] + area_b[None, :] - inter
    return np.where(union > 0, inter / union, 0.0)