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 background — matrix[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
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)
|