From 6157ba125d6434468eb0163f9c9e5297768dca0a Mon Sep 17 00:00:00 2001 From: Dani Rogmans Date: Wed, 12 Aug 2026 14:10:24 +0200 Subject: [PATCH 1/6] standardization of predictions and metric results, more moving around and cleanup --- luxonis_eval/__init__.py | 8 +- luxonis_eval/__main__.py | 5 +- luxonis_eval/core/__init__.py | 28 +- luxonis_eval/core/core.py | 141 +++--- luxonis_eval/core/reporting.py | 46 +- luxonis_eval/core/results.py | 31 ++ luxonis_eval/metrics/base_metric.py | 11 +- luxonis_eval/metrics/bbox_map.py | 11 +- luxonis_eval/metrics/dice_coef.py | 51 +-- luxonis_eval/metrics/f1_score.py | 84 ++-- luxonis_eval/metrics/jaccard_index.py | 84 ++-- luxonis_eval/metrics/keypoint_map.py | 7 +- luxonis_eval/metrics/mIoU.py | 51 +-- luxonis_eval/metrics/mask_map.py | 13 +- luxonis_eval/metrics/throughput.py | 49 ++- luxonis_eval/metrics/topk_accuracy.py | 10 +- luxonis_eval/metrics/utils/__init__.py | 13 + luxonis_eval/metrics/utils/segmentation.py | 125 ++++++ luxonis_eval/parsers/__init__.py | 2 + luxonis_eval/parsers/base_parser.py | 6 +- luxonis_eval/parsers/classification.py | 15 +- luxonis_eval/parsers/predictions.py | 47 ++ luxonis_eval/parsers/segmentation.py | 7 +- luxonis_eval/parsers/utils/__init__.py | 6 + luxonis_eval/parsers/utils/yolo.py | 457 ++++++++++++++++++++ luxonis_eval/parsers/yolo.py | 439 ++----------------- luxonis_eval/utils/json_utils.py | 15 +- luxonis_eval/utils/utils.py | 20 - luxonis_eval/visualizers/base_visualizer.py | 4 +- 29 files changed, 1010 insertions(+), 776 deletions(-) create mode 100644 luxonis_eval/core/results.py create mode 100644 luxonis_eval/metrics/utils/__init__.py create mode 100644 luxonis_eval/metrics/utils/segmentation.py create mode 100644 luxonis_eval/parsers/predictions.py create mode 100644 luxonis_eval/parsers/utils/__init__.py create mode 100644 luxonis_eval/parsers/utils/yolo.py diff --git a/luxonis_eval/__init__.py b/luxonis_eval/__init__.py index a709f3e..1f03681 100644 --- a/luxonis_eval/__init__.py +++ b/luxonis_eval/__init__.py @@ -6,7 +6,13 @@ __version__: Final[str] = "0.0.1" __semver__: Final[SemanticVersion] = SemanticVersion.parse(__version__) -from .core import LuxonisEval # noqa: F401 +from .core import ( + EvaluationResult, + LuxonisEval, + MetricsResult, + MetricValues, + ThroughputResult, +) # noqa: F401 from .engines import * from .loaders import * from .metrics import * diff --git a/luxonis_eval/__main__.py b/luxonis_eval/__main__.py index 4cfd62f..7bb8078 100644 --- a/luxonis_eval/__main__.py +++ b/luxonis_eval/__main__.py @@ -6,6 +6,7 @@ from luxonis_eval.config import EvalConfig from luxonis_eval.core import LuxonisEval +from luxonis_eval.core.results import EvaluationResult from luxonis_eval.utils.json_utils import write_output_json app = App( @@ -21,7 +22,7 @@ def eval_run( cfg: PathType | Params | EvalConfig, opts: Params | list[str] | tuple[str, ...] | None = None, output_json: str | None = None, -) -> dict[str, Any]: +) -> EvaluationResult: """Run evaluation with the given configuration.""" # Temporary: until benchmark execution is implemented, `eval` delegates # to the quality-only path. @@ -32,7 +33,7 @@ def quality_run( cfg: PathType | Params | EvalConfig, opts: Params | list[str] | tuple[str, ...] | None = None, output_json: str | None = None, -) -> dict[str, Any]: +) -> EvaluationResult: """Run the configured quality evaluators.""" evaluator = LuxonisEval(cfg, opts) evaluator.setup() diff --git a/luxonis_eval/core/__init__.py b/luxonis_eval/core/__init__.py index dde0767..5a521f8 100644 --- a/luxonis_eval/core/__init__.py +++ b/luxonis_eval/core/__init__.py @@ -1,3 +1,27 @@ -from .core import LuxonisEval +from typing import TYPE_CHECKING -__all__ = ["LuxonisEval"] +from .results import ( + EvaluationResult, + MetricsResult, + MetricValues, + ThroughputResult, +) + +if TYPE_CHECKING: + from .core import LuxonisEval + + +def __getattr__(name: str) -> object: + if name == "LuxonisEval": + from .core import LuxonisEval + + return LuxonisEval + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__all__ = [ + "EvaluationResult", + "LuxonisEval", + "MetricsResult", + "MetricValues", + "ThroughputResult", +] diff --git a/luxonis_eval/core/core.py b/luxonis_eval/core/core.py index eee21a3..b9f77d6 100644 --- a/luxonis_eval/core/core.py +++ b/luxonis_eval/core/core.py @@ -18,8 +18,8 @@ from luxonis_eval.core.reporting import ( RichProgressAdapter, TQDMProgressAdapter, + format_evaluation_result, get_model_name, - make_report_table, ) from luxonis_eval.core.runtime import ( build_metric_contexts, @@ -36,7 +36,7 @@ from luxonis_eval.metrics import ThroughputMetric from luxonis_eval.metrics.base_metric import BaseMetric from luxonis_eval.parsers.base_parser import BaseParser -from luxonis_eval.parsers.yolo import clear_prediction_metadata +from luxonis_eval.core.results import EvaluationResult from luxonis_eval.visualizers.base_visualizer import BaseVisualizer @@ -123,20 +123,20 @@ def setup(self) -> None: self._is_setup = True self._is_closed = False - def evaluate(self) -> dict[str, Any]: + def evaluate(self) -> EvaluationResult: """Run the evaluation loop and return structured results.""" self._require_setup() self._reset_runtime_metrics() return self._run_pipeline() - def _run_pipeline(self) -> dict[str, Any]: + def _run_pipeline(self) -> EvaluationResult: if self.cfg.pipeline.benchmark is not None: logger.warning( "pipeline.benchmark is configured, but benchmark execution is not implemented yet. Running quality evaluators only." ) return self._run_evaluators() - def _run_evaluators(self) -> dict[str, Any]: + def _run_evaluators(self) -> EvaluationResult: """Run the configured quality evaluator.""" self._require_setup() engine_name = self.cfg.pipeline.engine.name @@ -177,75 +177,65 @@ def _run_evaluators(self) -> dict[str, Any]: ) parsing_elapsed = time.perf_counter() - parsing_t0 - try: - metric_update_t0 = time.perf_counter() - for metric, metric_ctx in zip( - self.metrics, self.metric_contexts, strict=True - ): - metric.update( - predictions=predictions, - target=target, - **metric_ctx, - ) - metric_update_elapsed = ( - time.perf_counter() - metric_update_t0 + metric_update_t0 = time.perf_counter() + for metric, metric_ctx in zip( + self.metrics, self.metric_contexts, strict=True + ): + metric.update( + predictions=predictions, + target=target, + **metric_ctx, ) + metric_update_elapsed = ( + time.perf_counter() - metric_update_t0 + ) - self.throughput_metric.update( - inference=inference_elapsed, - parsing=parsing_elapsed, - metric_update=metric_update_elapsed, - ) + self.throughput_metric.update( + inference=inference_elapsed, + parsing=parsing_elapsed, + metric_update=metric_update_elapsed, + ) - active_visualizer_cfgs = [ - visualizer_cfg - for visualizer_cfg in self.evaluator_cfg.visualizers - if visualizer_cfg.active - ] - for visualizer, visualizer_cfg in zip( - self.visualizers, - active_visualizer_cfgs, - strict=True, - ): - visualizer.visualize( - predictions, - self.engine.vis_frame(), - **visualizer_cfg.params, - ) - finally: - clear_prediction_metadata(predictions) + active_visualizer_cfgs = [ + visualizer_cfg + for visualizer_cfg in self.evaluator_cfg.visualizers + if visualizer_cfg.active + ] + for visualizer, visualizer_cfg in zip( + self.visualizers, + active_visualizer_cfgs, + strict=True, + ): + visualizer.visualize( + predictions, + self.engine.vis_frame(), + **visualizer_cfg.params, + ) progress.update(advance=1) metric_compute_t0 = time.perf_counter() - results = [ - (metric.__class__.__name__, metric.compute()) + results = { + metric.__class__.__name__: metric.compute() for metric in self.metrics - ] + } metric_compute_elapsed = time.perf_counter() - metric_compute_t0 throughput = self.throughput_metric.compute( metric_compute=metric_compute_elapsed ) - report = make_report_table( - engine_name=engine_name, + result = EvaluationResult( + evaluator_name=self.evaluator_cfg.name, + engine=engine_name, model_name=model_name, - tp=throughput, - results=results, + metrics=results, + throughput=throughput, ) - logger.warning( "Throughput values are end-to-end pipeline measurements and not isolated model-only benchmarks. Lower numbers than modelconverter benchmark results are expected." ) - logger.info(f"\n{report}") - - return { - "evaluator_name": self.evaluator_cfg.name, - "engine": engine_name, - "model_name": model_name, - "metrics": results, - "throughput": throughput, - "report": report, - } + logger.info(f"\n{format_evaluation_result(result)}") + + return result def close(self) -> None: """Release owned runtime resources.""" @@ -292,27 +282,24 @@ def _sanity_check_pipeline(self) -> None: **self.evaluator_cfg.parser.params, ) - try: - for metric, metric_ctx in zip( - self.metrics, self.metric_contexts, strict=True - ): - missing = set(metric.required_target_keys()) - set(target) - if missing: - raise ValueError( - "Target is missing required keys for " - f"{metric.__class__.__name__}: {sorted(missing)}. " - f"Got keys: {sorted(target.keys())}." - ) - - metric.update( - predictions=predictions, - target=target, - **metric_ctx, + for metric, metric_ctx in zip( + self.metrics, self.metric_contexts, strict=True + ): + missing = set(metric.required_target_keys()) - set(target) + if missing: + raise ValueError( + "Target is missing required keys for " + f"{metric.__class__.__name__}: {sorted(missing)}. " + f"Got keys: {sorted(target.keys())}." ) - metric.compute() - metric.reset() - finally: - clear_prediction_metadata(predictions) + + metric.update( + predictions=predictions, + target=target, + **metric_ctx, + ) + metric.compute() + metric.reset() def _clear_runtime_fields(self) -> None: self.engine: BaseEngine | None = None diff --git a/luxonis_eval/core/reporting.py b/luxonis_eval/core/reporting.py index d7aac59..aa9bc00 100644 --- a/luxonis_eval/core/reporting.py +++ b/luxonis_eval/core/reporting.py @@ -14,6 +14,11 @@ from tabulate import tabulate from tqdm.auto import tqdm +from luxonis_eval.core.results import ( + EvaluationResult, + ThroughputResult, +) + class TQDMProgressAdapter(AbstractContextManager["TQDMProgressAdapter"]): def __init__(self, description: str, total: int) -> None: @@ -79,15 +84,10 @@ def section( return [[centered, ""]] -def make_report_table( - engine_name: str, - model_name: str, - tp: dict[str, float | int], - results: list[tuple[str, dict[str, Any]]], -) -> str: - def format_stage(name: str) -> str: - ms = float(tp[f"{name}_ms_per_sample"]) - total = float(tp["ms_per_sample"]) +def format_evaluation_result(result: EvaluationResult) -> str: + def format_stage(name: str, tp: ThroughputResult) -> str: + ms = float(getattr(tp, f"{name}_ms_per_sample")) + total = float(tp.ms_per_sample) pct = (ms / total * 100.0) if total else 0.0 return f"{ms:5.2f} ms | {pct:4.1f}%" @@ -95,29 +95,35 @@ def format_stage(name: str) -> str: rows += section("SETTINGS") rows += [ - ["Model", model_name], - ["Engine", str(engine_name).upper()], + ["Model", result.model_name], + ["Engine", str(result.engine).upper()], ] rows += section("PERFORMANCE") rows += [ - ["Throughput", f"{tp['samples_per_s']:.2f} samples/s"], - ["End-to-end Latency", f"{tp['ms_per_sample']:.2f} ms/sample"], + [ + "Throughput", + f"{result.throughput.samples_per_s:.2f} samples/s", + ], + [ + "End-to-end Latency", + f"{result.throughput.ms_per_sample:.2f} ms/sample", + ], ] rows += section("STAGE BREAKDOWN", line_char="-") rows += [ - ["Inference", format_stage("inference")], - ["Parsing", format_stage("parsing")], - ["Metric Update", format_stage("metric_update")], - ["Metric Compute", format_stage("metric_compute")], - ["Pipeline Overhead", format_stage("overhead")], + ["Inference", format_stage("inference", result.throughput)], + ["Parsing", format_stage("parsing", result.throughput)], + ["Metric Update", format_stage("metric_update", result.throughput)], + ["Metric Compute", format_stage("metric_compute", result.throughput)], + ["Pipeline Overhead", format_stage("overhead", result.throughput)], ] rows += section("QUALITY") - for metric_name, result in results: + for metric_name, metric_values in result.metrics.items(): rows += section(metric_name, line_char="-") - for k, v in result.items(): + for k, v in metric_values.items(): val = f"{v * 100:.2f}%" if isinstance(v, float) else str(v) rows.append([str(k), val]) diff --git a/luxonis_eval/core/results.py b/luxonis_eval/core/results.py new file mode 100644 index 0000000..07dd4c0 --- /dev/null +++ b/luxonis_eval/core/results.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + + +MetricValues = dict[str, float] +MetricsResult = dict[str, MetricValues] + + +@dataclass(slots=True) +class ThroughputResult: + """End-to-end throughput and latency measurements.""" + + elapsed_s: float + samples: int + samples_per_s: float + ms_per_sample: float + overhead_ms_per_sample: float + inference_ms_per_sample: float + parsing_ms_per_sample: float + metric_update_ms_per_sample: float + metric_compute_ms_per_sample: float + + +@dataclass(slots=True) +class EvaluationResult: + """Structured output returned by ``LuxonisEval.evaluate()``.""" + + evaluator_name: str + engine: str + model_name: str + metrics: MetricsResult + throughput: ThroughputResult diff --git a/luxonis_eval/metrics/base_metric.py b/luxonis_eval/metrics/base_metric.py index ca320cf..4a6a1d8 100644 --- a/luxonis_eval/metrics/base_metric.py +++ b/luxonis_eval/metrics/base_metric.py @@ -4,9 +4,9 @@ import numpy as np from luxonis_ml.utils.registry import AutoRegisterMeta +from luxonis_eval.parsers.predictions import Prediction from luxonis_eval.registry import METRICS_REGISTRY - class BaseMetric( ABC, metaclass=AutoRegisterMeta, @@ -37,14 +37,17 @@ def reset(self) -> None: @abstractmethod def update( - self, predictions: Any, target: dict[str, np.ndarray], **kwargs: Any + self, + predictions: Prediction, + target: dict[str, np.ndarray], + **kwargs: Any, ) -> None: """Update the metric with predictions and ground truths. Parameters ---------- - predictions : Any - Model predictions. + predictions : Prediction + Structured model predictions. target : dict[str, np.ndarray] Ground-truth data. **kwargs : Any diff --git a/luxonis_eval/metrics/bbox_map.py b/luxonis_eval/metrics/bbox_map.py index 289cbab..29ade93 100644 --- a/luxonis_eval/metrics/bbox_map.py +++ b/luxonis_eval/metrics/bbox_map.py @@ -1,11 +1,11 @@ from collections.abc import Sequence from typing import Any -import depthai as dai import numpy as np from luxonis_eval.metrics.base_metric import BaseMetric from luxonis_eval.metrics.metrics_utils import detection_to_coco_xywh +from luxonis_eval.parsers.predictions import Prediction from luxonis_eval.utils.coco_utils import COCOStore @@ -44,7 +44,7 @@ def reset(self) -> None: def update( self, - predictions: dai.ImgDetections, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: @@ -52,13 +52,14 @@ def update( Parameters ---------- - predictions : dai.ImgDetections - Model predictions. + predictions : Prediction + Structured detection predictions. target : dict[str, np.ndarray] Ground-truth data. **kwargs : Any Additional context. """ + detections = predictions.require_detections() target_boxes = target[self.required_target_keys()[0]] width = int(kwargs["width"]) height = int(kwargs["height"]) @@ -104,7 +105,7 @@ def update( ) # --- Predictions --- - for pred in predictions.detections: + for pred in detections.detections: cls = int(pred.label) if ( self._store.category_ids_set is not None diff --git a/luxonis_eval/metrics/dice_coef.py b/luxonis_eval/metrics/dice_coef.py index 152a549..fda047a 100644 --- a/luxonis_eval/metrics/dice_coef.py +++ b/luxonis_eval/metrics/dice_coef.py @@ -1,18 +1,11 @@ from typing import Any, Literal -import depthai as dai import numpy as np -import torch from torchmetrics.segmentation import DiceScore from luxonis_eval.metrics.base_metric import BaseMetric -from luxonis_eval.metrics.metrics_utils import ( - mask_ignore_pixels, - normalize_prediction_segmentation_mask, - remap_prediction_mask, - target_segmentation_to_index_mask, -) -from luxonis_eval.utils.utils import extract_segmentation_mask +from luxonis_eval.metrics.utils import prepare_segmentation_metric_inputs +from luxonis_eval.parsers.predictions import Prediction class DiceCoefficient(BaseMetric): @@ -69,7 +62,7 @@ def reset(self) -> None: def update( self, - predictions: dai.SegmentationMask, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: @@ -77,8 +70,8 @@ def update( Parameters ---------- - predictions : SegmentationMask - Model predictions (logits or probabilities). + predictions : Prediction + Structured segmentation predictions. target : dict[str, np.ndarray] Ground-truth labels. **kwargs : Any @@ -86,33 +79,15 @@ def update( """ if self.target_class_map is None: self.target_class_map = kwargs.get("target_class_map", {}) - target_bg = kwargs.get("target_bg") - class_index_map = kwargs.get("class_index_map") - - target_mask, binary_target = target_segmentation_to_index_mask( - target[self.required_target_keys()[0]] - ) - pred_mask = normalize_prediction_segmentation_mask( - extract_segmentation_mask(predictions), - binary_target=binary_target, - ) - - if class_index_map is not None and not binary_target: - pred_mask = remap_prediction_mask(pred_mask, class_index_map) - - if ( - not binary_target - and not self.include_background - and target_bg is not None - ): - pred_mask, target_mask = mask_ignore_pixels( - pred_mask, target_mask, ignore_index=target_bg - ) - - self.metric.update( - torch.from_numpy(pred_mask.astype(np.int64)), - torch.from_numpy(target_mask.astype(np.int64)), + prepared = prepare_segmentation_metric_inputs( + predictions, + target, + include_background=self.include_background, + target_key=self.required_target_keys()[0], + target_bg=kwargs.get("target_bg"), + class_index_map=kwargs.get("class_index_map"), ) + self.metric.update(prepared.pred_tensor, prepared.target_tensor) def compute(self) -> dict[str, float]: """Compute final Dice coefficient metrics. diff --git a/luxonis_eval/metrics/f1_score.py b/luxonis_eval/metrics/f1_score.py index 8f077e1..af8c9a1 100644 --- a/luxonis_eval/metrics/f1_score.py +++ b/luxonis_eval/metrics/f1_score.py @@ -1,18 +1,16 @@ from typing import Any, Literal -import depthai as dai import numpy as np import torch import torchmetrics from luxonis_eval.metrics.base_metric import BaseMetric -from luxonis_eval.metrics.metrics_utils import ( - mask_ignore_pixels, - normalize_prediction_segmentation_mask, - remap_prediction_mask, - target_segmentation_to_index_mask, +from luxonis_eval.metrics.utils import ( + format_torchmetric_result, + infer_num_classes, + prepare_segmentation_metric_inputs, ) -from luxonis_eval.utils.utils import extract_segmentation_mask +from luxonis_eval.parsers.predictions import Prediction class F1Score(BaseMetric): @@ -44,63 +42,38 @@ def reset(self) -> None: def update( self, - predictions: dai.SegmentationMask, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: if self.target_class_map is None: self.target_class_map = kwargs.get("target_class_map", {}) - target_bg = kwargs.get("target_bg") - class_index_map = kwargs.get("class_index_map") - - target_mask, binary_target = target_segmentation_to_index_mask( - target[self.required_target_keys()[0]] - ) - pred_mask = normalize_prediction_segmentation_mask( - extract_segmentation_mask(predictions), - binary_target=binary_target, + prepared = prepare_segmentation_metric_inputs( + predictions, + target, + include_background=self.include_background, + target_key=self.required_target_keys()[0], + target_bg=kwargs.get("target_bg"), + class_index_map=kwargs.get("class_index_map"), ) - if class_index_map is not None and not binary_target: - pred_mask = remap_prediction_mask(pred_mask, class_index_map) - - if ( - not binary_target - and not self.include_background - and target_bg is not None - ): - pred_mask, target_mask = mask_ignore_pixels( - pred_mask, target_mask, ignore_index=target_bg - ) - - pred_tensor = torch.from_numpy(pred_mask.astype(np.int64)) - target_tensor = torch.from_numpy(target_mask.astype(np.int64)) - if self.metric is None: self.metric = self._create_metric( - target_mask=target_tensor, - binary_target=binary_target, + target_mask=prepared.target_tensor, + binary_target=prepared.binary_target, ) - self.metric.update(pred_tensor, target_tensor) + self.metric.update(prepared.pred_tensor, prepared.target_tensor) def compute(self) -> dict[str, float]: if self.metric is None: return {"F1Score": 0.0} - result = self.metric.compute() - if result.ndim == 0 or result.numel() == 1: - return {"F1Score": float(result)} - - class_names = [ - self.target_class_map.get(i, f"class_{i}") - if self.target_class_map is not None - else f"class_{i}" - for i in range(result.numel()) - ] - return { - f"{type(self.metric).__name__}_{class_name}": float(value) - for class_name, value in zip(class_names, result, strict=True) - } + return format_torchmetric_result( + self.metric.compute(), + scalar_name="F1Score", + per_class_prefix=type(self.metric).__name__, + target_class_map=self.target_class_map, + ) def _create_metric( self, @@ -111,15 +84,12 @@ def _create_metric( if binary_target: return torchmetrics.F1Score(task="binary", average=average) - num_classes = self.num_classes - if num_classes is None: - if self.target_class_map: - num_classes = len(self.target_class_map) - else: - num_classes = int(target_mask.max().item()) + 1 - return torchmetrics.F1Score( task="multiclass", - num_classes=num_classes, + num_classes=infer_num_classes( + target_mask, + configured_num_classes=self.num_classes, + target_class_map=self.target_class_map, + ), average=average, ) diff --git a/luxonis_eval/metrics/jaccard_index.py b/luxonis_eval/metrics/jaccard_index.py index 80a1a9a..af04b17 100644 --- a/luxonis_eval/metrics/jaccard_index.py +++ b/luxonis_eval/metrics/jaccard_index.py @@ -1,18 +1,16 @@ from typing import Any, Literal -import depthai as dai import numpy as np import torch import torchmetrics from luxonis_eval.metrics.base_metric import BaseMetric -from luxonis_eval.metrics.metrics_utils import ( - mask_ignore_pixels, - normalize_prediction_segmentation_mask, - remap_prediction_mask, - target_segmentation_to_index_mask, +from luxonis_eval.metrics.utils import ( + format_torchmetric_result, + infer_num_classes, + prepare_segmentation_metric_inputs, ) -from luxonis_eval.utils.utils import extract_segmentation_mask +from luxonis_eval.parsers.predictions import Prediction class JaccardIndex(BaseMetric): @@ -43,63 +41,38 @@ def reset(self) -> None: def update( self, - predictions: dai.SegmentationMask, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: if self.target_class_map is None: self.target_class_map = kwargs.get("target_class_map", {}) - target_bg = kwargs.get("target_bg") - class_index_map = kwargs.get("class_index_map") - - target_mask, binary_target = target_segmentation_to_index_mask( - target[self.required_target_keys()[0]] - ) - pred_mask = normalize_prediction_segmentation_mask( - extract_segmentation_mask(predictions), - binary_target=binary_target, + prepared = prepare_segmentation_metric_inputs( + predictions, + target, + include_background=self.include_background, + target_key=self.required_target_keys()[0], + target_bg=kwargs.get("target_bg"), + class_index_map=kwargs.get("class_index_map"), ) - if class_index_map is not None and not binary_target: - pred_mask = remap_prediction_mask(pred_mask, class_index_map) - - if ( - not binary_target - and not self.include_background - and target_bg is not None - ): - pred_mask, target_mask = mask_ignore_pixels( - pred_mask, target_mask, ignore_index=target_bg - ) - - pred_tensor = torch.from_numpy(pred_mask.astype(np.int64)) - target_tensor = torch.from_numpy(target_mask.astype(np.int64)) - if self.metric is None: self.metric = self._create_metric( - target_mask=target_tensor, - binary_target=binary_target, + target_mask=prepared.target_tensor, + binary_target=prepared.binary_target, ) - self.metric.update(pred_tensor, target_tensor) + self.metric.update(prepared.pred_tensor, prepared.target_tensor) def compute(self) -> dict[str, float]: if self.metric is None: return {"JaccardIndex": 0.0} - result = self.metric.compute() - if result.ndim == 0 or result.numel() == 1: - return {"JaccardIndex": float(result)} - - class_names = [ - self.target_class_map.get(i, f"class_{i}") - if self.target_class_map is not None - else f"class_{i}" - for i in range(result.numel()) - ] - return { - f"{type(self.metric).__name__}_{class_name}": float(value) - for class_name, value in zip(class_names, result, strict=True) - } + return format_torchmetric_result( + self.metric.compute(), + scalar_name="JaccardIndex", + per_class_prefix=type(self.metric).__name__, + target_class_map=self.target_class_map, + ) def _create_metric( self, @@ -110,15 +83,12 @@ def _create_metric( if binary_target: return torchmetrics.JaccardIndex(task="binary") - num_classes = self.num_classes - if num_classes is None: - if self.target_class_map: - num_classes = len(self.target_class_map) - else: - num_classes = int(target_mask.max().item()) + 1 - return torchmetrics.JaccardIndex( task="multiclass", - num_classes=num_classes, + num_classes=infer_num_classes( + target_mask, + configured_num_classes=self.num_classes, + target_class_map=self.target_class_map, + ), average=average, ) diff --git a/luxonis_eval/metrics/keypoint_map.py b/luxonis_eval/metrics/keypoint_map.py index c8d6bec..e6b2003 100644 --- a/luxonis_eval/metrics/keypoint_map.py +++ b/luxonis_eval/metrics/keypoint_map.py @@ -1,7 +1,6 @@ from collections.abc import Sequence from typing import Any -import depthai as dai import numpy as np import torch from faster_coco_eval.core import COCO, COCOeval_faster @@ -13,6 +12,7 @@ bbox_area_from_keypoints, detection_to_coco_xywh, ) +from luxonis_eval.parsers.predictions import Prediction class KeypointMeanAveragePrecision(BaseMetric): @@ -64,10 +64,11 @@ def reset(self) -> None: def update( self, - predictions: dai.ImgDetections, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: + detections = predictions.require_detections() target_boxes = target[self.required_target_keys()[0]] target_kpts = target[self.required_target_keys()[1]] width = int(kwargs["width"]) @@ -81,7 +82,7 @@ def update( pred_classes: list[int] = [] pred_keypoints: list[list[float]] = [] - for det in predictions.detections: + for det in detections.detections: cls = int(det.label) keypoints_flat = [ diff --git a/luxonis_eval/metrics/mIoU.py b/luxonis_eval/metrics/mIoU.py index 8951945..a689158 100644 --- a/luxonis_eval/metrics/mIoU.py +++ b/luxonis_eval/metrics/mIoU.py @@ -1,18 +1,11 @@ from typing import Any, Literal -import depthai as dai import numpy as np -import torch from torchmetrics.segmentation import MeanIoU from luxonis_eval.metrics.base_metric import BaseMetric -from luxonis_eval.metrics.metrics_utils import ( - mask_ignore_pixels, - normalize_prediction_segmentation_mask, - remap_prediction_mask, - target_segmentation_to_index_mask, -) -from luxonis_eval.utils.utils import extract_segmentation_mask +from luxonis_eval.metrics.utils import prepare_segmentation_metric_inputs +from luxonis_eval.parsers.predictions import Prediction class MIoU(BaseMetric): @@ -69,7 +62,7 @@ def reset(self) -> None: def update( self, - predictions: dai.SegmentationMask, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: @@ -77,8 +70,8 @@ def update( Parameters ---------- - predictions : SegmentationMask - Model predictions (logits or probabilities). + predictions : Prediction + Structured segmentation predictions. target : dict[str, np.ndarray] Ground-truth labels. **kwargs : Any @@ -87,33 +80,15 @@ def update( # Retrieve additional metric-specific options if self.target_class_map is None: self.target_class_map = kwargs.get("target_class_map", {}) - target_bg = kwargs.get("target_bg") - class_index_map = kwargs.get("class_index_map") - - target_mask, binary_target = target_segmentation_to_index_mask( - target[self.required_target_keys()[0]] - ) - pred_mask = normalize_prediction_segmentation_mask( - extract_segmentation_mask(predictions), - binary_target=binary_target, - ) - - if class_index_map is not None and not binary_target: - pred_mask = remap_prediction_mask(pred_mask, class_index_map) - - if ( - not binary_target - and not self.include_background - and target_bg is not None - ): - pred_mask, target_mask = mask_ignore_pixels( - pred_mask, target_mask, ignore_index=target_bg - ) - - self.metric.update( - torch.from_numpy(pred_mask.astype(np.int64)), - torch.from_numpy(target_mask.astype(np.int64)), + prepared = prepare_segmentation_metric_inputs( + predictions, + target, + include_background=self.include_background, + target_key=self.required_target_keys()[0], + target_bg=kwargs.get("target_bg"), + class_index_map=kwargs.get("class_index_map"), ) + self.metric.update(prepared.pred_tensor, prepared.target_tensor) def compute(self) -> dict[str, float]: """Compute final mIoU metrics. diff --git a/luxonis_eval/metrics/mask_map.py b/luxonis_eval/metrics/mask_map.py index d7adb4b..fb55cbd 100644 --- a/luxonis_eval/metrics/mask_map.py +++ b/luxonis_eval/metrics/mask_map.py @@ -1,7 +1,6 @@ from collections.abc import Sequence from typing import Any -import depthai as dai import numpy as np import torch from torchmetrics.detection import MeanAveragePrecision @@ -10,7 +9,7 @@ from luxonis_eval.metrics.metrics_utils import ( detection_to_coco_xywh, ) -from luxonis_eval.parsers.yolo import get_prediction_instance_masks +from luxonis_eval.parsers.predictions import Prediction class MaskMeanAveragePrecision(BaseMetric): @@ -55,7 +54,7 @@ def reset(self) -> None: def update( self, - predictions: dai.ImgDetections, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: @@ -63,8 +62,8 @@ def update( Parameters ---------- - predictions : dai.ImgDetections - Model predictions. + predictions : Prediction + Structured instance-segmentation predictions. target : dict[str, np.ndarray] Ground-truth data. **kwargs : Any @@ -94,9 +93,9 @@ def update( else None ) - detections = predictions.detections + detections = predictions.require_detections().detections masks = self._resolve_prediction_instance_masks( - get_prediction_instance_masks(predictions), + predictions.require_instance_masks(), n_detections=len(detections), height=height, width=width, diff --git a/luxonis_eval/metrics/throughput.py b/luxonis_eval/metrics/throughput.py index a1a7c27..858c9ee 100644 --- a/luxonis_eval/metrics/throughput.py +++ b/luxonis_eval/metrics/throughput.py @@ -1,5 +1,7 @@ import time +from luxonis_eval.core.results import ThroughputResult + class ThroughputMetric: """Throughput evaluation metric.""" @@ -38,7 +40,7 @@ def update( def compute( self, *, metric_compute: float = 0.0 - ) -> dict[str, float | int]: + ) -> ThroughputResult: """Compute final throughput metrics. Parameters @@ -48,7 +50,7 @@ def compute( Returns ------- - dict[str, float | int] + ThroughputResult Computed throughput results. """ self._stage_elapsed["metric_compute"] = metric_compute @@ -62,23 +64,38 @@ def compute( tracked_elapsed = sum(self._stage_elapsed.values()) overhead_elapsed = max(elapsed - tracked_elapsed, 0.0) - results: dict[str, float | int] = { - "elapsed_s": float(elapsed), - "samples": int(self._num_updates), - "samples_per_s": float(sps), - "ms_per_sample": float(msp), - "overhead_ms_per_sample": float( + return ThroughputResult( + elapsed_s=float(elapsed), + samples=int(self._num_updates), + samples_per_s=float(sps), + ms_per_sample=float(msp), + overhead_ms_per_sample=float( (overhead_elapsed / self._num_updates) * 1000.0 if self._num_updates else 0.0 ), - } - - for stage, stage_elapsed in self._stage_elapsed.items(): - results[f"{stage}_ms_per_sample"] = float( - (stage_elapsed / self._num_updates) * 1000.0 + inference_ms_per_sample=float( + (self._stage_elapsed["inference"] / self._num_updates) + * 1000.0 if self._num_updates else 0.0 - ) - - return results + ), + parsing_ms_per_sample=float( + (self._stage_elapsed["parsing"] / self._num_updates) + * 1000.0 + if self._num_updates + else 0.0 + ), + metric_update_ms_per_sample=float( + (self._stage_elapsed["metric_update"] / self._num_updates) + * 1000.0 + if self._num_updates + else 0.0 + ), + metric_compute_ms_per_sample=float( + (self._stage_elapsed["metric_compute"] / self._num_updates) + * 1000.0 + if self._num_updates + else 0.0 + ), + ) diff --git a/luxonis_eval/metrics/topk_accuracy.py b/luxonis_eval/metrics/topk_accuracy.py index 0fba892..e076028 100644 --- a/luxonis_eval/metrics/topk_accuracy.py +++ b/luxonis_eval/metrics/topk_accuracy.py @@ -2,9 +2,9 @@ from typing import Any import numpy as np -from depthai_nodes import Classifications from luxonis_eval.metrics.base_metric import BaseMetric +from luxonis_eval.parsers.predictions import Prediction class TopKAccuracy(BaseMetric): @@ -40,7 +40,7 @@ def reset(self) -> None: def update( self, - predictions: Classifications, + predictions: Prediction, target: dict[str, np.ndarray], **kwargs: Any, ) -> None: @@ -48,8 +48,8 @@ def update( Parameters ---------- - predictions : Classifications - Model predictions (logits or probabilities). + predictions : Prediction + Structured classification predictions. target : dict[str, np.ndarray] Ground-truth labels. **kwargs : Any @@ -62,7 +62,7 @@ def update( topk = tuple(kwargs.get("topk", self.topk)) - pred_classes = predictions.classes + pred_classes = predictions.require_classification().classes tgt = np.asarray(cls_target) target_idx = ( diff --git a/luxonis_eval/metrics/utils/__init__.py b/luxonis_eval/metrics/utils/__init__.py new file mode 100644 index 0000000..893c635 --- /dev/null +++ b/luxonis_eval/metrics/utils/__init__.py @@ -0,0 +1,13 @@ +from .segmentation import ( + PreparedSegmentationData, + format_torchmetric_result, + infer_num_classes, + prepare_segmentation_metric_inputs, +) + +__all__ = [ + "PreparedSegmentationData", + "format_torchmetric_result", + "infer_num_classes", + "prepare_segmentation_metric_inputs", +] diff --git a/luxonis_eval/metrics/utils/segmentation.py b/luxonis_eval/metrics/utils/segmentation.py new file mode 100644 index 0000000..46fe981 --- /dev/null +++ b/luxonis_eval/metrics/utils/segmentation.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass +from typing import Mapping + +import depthai as dai +import numpy as np +import torch + +from luxonis_eval.metrics.metrics_utils import ( + mask_ignore_pixels, + normalize_prediction_segmentation_mask, + remap_prediction_mask, + target_segmentation_to_index_mask, +) +from luxonis_eval.parsers.predictions import Prediction + + +@dataclass(slots=True) +class PreparedSegmentationData: + """Normalized segmentation data ready for metric updates.""" + + pred_mask: np.ndarray + target_mask: np.ndarray + pred_tensor: torch.Tensor + target_tensor: torch.Tensor + binary_target: bool + + +def extract_segmentation_mask( + predictions: Prediction | dai.SegmentationMask, +) -> np.ndarray: + """Extract a semantic-segmentation mask from a prediction payload.""" + if isinstance(predictions, Prediction): + predictions = predictions.require_segmentation_mask() + + if hasattr(predictions, "getCvMask"): + mask = predictions.getCvMask() + elif hasattr(predictions, "getCvSegmentationMask"): + mask = predictions.getCvSegmentationMask() + else: + raise TypeError( + "Unsupported segmentation prediction type " + f"{type(predictions)!r}: expected a DepthAI SegmentationMask " + "message." + ) + + if mask is None: + raise ValueError("Segmentation prediction does not contain a mask.") + + return np.asarray(mask) + + +def prepare_segmentation_metric_inputs( + predictions: Prediction, + target: dict[str, np.ndarray], + *, + include_background: bool, + target_key: str = "/segmentation", + target_bg: int | None = None, + class_index_map: dict[int, int] | None = None, +) -> PreparedSegmentationData: + """Normalize segmentation predictions and targets for metric updates.""" + target_mask, binary_target = target_segmentation_to_index_mask( + target[target_key] + ) + pred_mask = normalize_prediction_segmentation_mask( + extract_segmentation_mask(predictions), + binary_target=binary_target, + ) + + if class_index_map is not None and not binary_target: + pred_mask = remap_prediction_mask(pred_mask, class_index_map) + + if not binary_target and not include_background and target_bg is not None: + pred_mask, target_mask = mask_ignore_pixels( + pred_mask, + target_mask, + ignore_index=target_bg, + ) + + pred_tensor = torch.from_numpy(pred_mask.astype(np.int64)) + target_tensor = torch.from_numpy(target_mask.astype(np.int64)) + return PreparedSegmentationData( + pred_mask=pred_mask, + target_mask=target_mask, + pred_tensor=pred_tensor, + target_tensor=target_tensor, + binary_target=binary_target, + ) + + +def infer_num_classes( + target_tensor: torch.Tensor, + *, + configured_num_classes: int | None, + target_class_map: Mapping[int, str] | None, +) -> int: + """Resolve the effective class count for multiclass metrics.""" + if configured_num_classes is not None: + return configured_num_classes + if target_class_map: + return len(target_class_map) + return int(target_tensor.max().item()) + 1 + + +def format_torchmetric_result( + result: torch.Tensor, + *, + scalar_name: str, + per_class_prefix: str, + target_class_map: Mapping[int, str] | None = None, +) -> dict[str, float]: + """Format scalar or per-class torchmetrics output consistently.""" + if result.ndim == 0 or result.numel() == 1: + return {scalar_name: float(result)} + + class_names = [ + target_class_map.get(index, f"class_{index}") + if target_class_map is not None + else f"class_{index}" + for index in range(result.numel()) + ] + return { + f"{per_class_prefix}_{class_name}": float(value) + for class_name, value in zip(class_names, result, strict=True) + } diff --git a/luxonis_eval/parsers/__init__.py b/luxonis_eval/parsers/__init__.py index 2332084..dbbdbc8 100755 --- a/luxonis_eval/parsers/__init__.py +++ b/luxonis_eval/parsers/__init__.py @@ -1,11 +1,13 @@ from .base_parser import BaseParser from .classification import ClassificationParser +from .predictions import Prediction from .segmentation import SegmentationParser from .yolo import YOLOExtendedParser __all__ = [ "BaseParser", "ClassificationParser", + "Prediction", "SegmentationParser", "YOLOExtendedParser", ] diff --git a/luxonis_eval/parsers/base_parser.py b/luxonis_eval/parsers/base_parser.py index 9208851..0ef94df 100644 --- a/luxonis_eval/parsers/base_parser.py +++ b/luxonis_eval/parsers/base_parser.py @@ -5,9 +5,9 @@ from luxonis_eval.engines.base_engine import ModelSpec from luxonis_eval.engines.io import EngineOutput +from luxonis_eval.parsers.predictions import Prediction from luxonis_eval.registry import PARSERS_REGISTRY - class BaseParser( ABC, metaclass=AutoRegisterMeta, @@ -31,6 +31,6 @@ def parse( output: EngineOutput, model_spec: ModelSpec, **kwargs: Any, - ) -> Any: - """Parse raw backend output into predictions.""" + ) -> Prediction: + """Parse raw backend output into a structured prediction.""" ... diff --git a/luxonis_eval/parsers/classification.py b/luxonis_eval/parsers/classification.py index 00c5266..ed7ff70 100644 --- a/luxonis_eval/parsers/classification.py +++ b/luxonis_eval/parsers/classification.py @@ -1,7 +1,6 @@ from typing import Any import numpy as np -from depthai_nodes import Classifications from depthai_nodes.message.creators import create_classification_message from depthai_nodes.node.parsers.classification import ( ClassificationParser as DepthAINodesClassificationParser, @@ -10,6 +9,7 @@ from luxonis_eval.engines.base_engine import ModelSpec from luxonis_eval.engines.io import EngineOutput from luxonis_eval.parsers.base_parser import BaseParser +from luxonis_eval.parsers.predictions import Prediction from luxonis_eval.utils.utils import ordered_class_names @@ -28,7 +28,7 @@ def parse( class_map: dict[int, str], apply_softmax: bool = False, **kwargs: Any, - ) -> Classifications: + ) -> Prediction: """Parse backend output into class scores. Parameters @@ -44,8 +44,8 @@ def parse( Returns ------- - Classifications - Classification scores. + Prediction + Structured classification scores. """ del model_spec, kwargs classes = ordered_class_names(class_map) @@ -73,4 +73,9 @@ def parse( is_softmax=True, ) - return create_classification_message(classes=classes, scores=scores) + return Prediction( + classification=create_classification_message( + classes=classes, + scores=scores, + ) + ) diff --git a/luxonis_eval/parsers/predictions.py b/luxonis_eval/parsers/predictions.py new file mode 100644 index 0000000..ed425a6 --- /dev/null +++ b/luxonis_eval/parsers/predictions.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + +import depthai as dai +import numpy as np +from depthai_nodes import Classifications + + +@dataclass(frozen=True, slots=True) +class Prediction: + """Structured parser output with optional task-specific payloads.""" + + classification: Classifications | None = None + segmentation_mask: dai.SegmentationMask | None = None + detections: dai.ImgDetections | None = None + instance_masks: np.ndarray | None = None + + def require_classification(self) -> Classifications: + if self.classification is None: + raise TypeError( + "Prediction does not contain classification scores." + ) + return self.classification + + def require_segmentation_mask(self) -> dai.SegmentationMask: + if self.segmentation_mask is None: + raise TypeError( + "Prediction does not contain a segmentation mask." + ) + return self.segmentation_mask + + def require_detections(self) -> dai.ImgDetections: + if self.detections is None: + raise TypeError( + "Prediction does not contain detections." + ) + return self.detections + + def require_instance_masks(self) -> np.ndarray: + if self.instance_masks is None: + raise TypeError( + "Prediction does not contain per-instance masks." + ) + return self.instance_masks + + @property + def classes(self) -> list[str]: + return self.require_classification().classes diff --git a/luxonis_eval/parsers/segmentation.py b/luxonis_eval/parsers/segmentation.py index ab93cb3..c744b35 100644 --- a/luxonis_eval/parsers/segmentation.py +++ b/luxonis_eval/parsers/segmentation.py @@ -9,6 +9,7 @@ from luxonis_eval.engines.base_engine import ModelSpec from luxonis_eval.engines.io import EngineOutput +from luxonis_eval.parsers.predictions import Prediction from .base_parser import BaseParser @@ -27,7 +28,7 @@ def parse( *, classes_in_one_layer: bool = False, **kwargs: Any, - ) -> dai.SegmentationMask: + ) -> Prediction: """Parse backend output into segmentation predictions.""" del model_spec, kwargs _, segmentation_mask = output.get_first() @@ -36,4 +37,6 @@ def parse( classes_in_one_layer=classes_in_one_layer, ) - return create_segmentation_message(class_map) + return Prediction( + segmentation_mask=create_segmentation_message(class_map) + ) diff --git a/luxonis_eval/parsers/utils/__init__.py b/luxonis_eval/parsers/utils/__init__.py new file mode 100644 index 0000000..37c599d --- /dev/null +++ b/luxonis_eval/parsers/utils/__init__.py @@ -0,0 +1,6 @@ +from .yolo import build_yolo_compute_inputs, build_yolo_instance_masks + +__all__ = [ + "build_yolo_compute_inputs", + "build_yolo_instance_masks", +] diff --git a/luxonis_eval/parsers/utils/yolo.py b/luxonis_eval/parsers/utils/yolo.py new file mode 100644 index 0000000..5fc91b1 --- /dev/null +++ b/luxonis_eval/parsers/utils/yolo.py @@ -0,0 +1,457 @@ +from dataclasses import dataclass + +import numpy as np +import torch +import torch.nn.functional as F +from depthai_nodes.node.parsers.yolo import YOLOComputeInputs +from depthai_nodes.node.parsers.utils.yolo import ( + YOLOSubtype, + decode_yolo26, + decode_yolo_output, + resolve_yolo_strides, +) + +from luxonis_eval.engines.base_engine import ModelSpec +from luxonis_eval.engines.io import EngineOutput +from luxonis_eval.utils.utils import ordered_class_names + + +@dataclass(frozen=True, slots=True) +class YoloTensorBundle: + layer_names: list[str] + outputs_values: list[np.ndarray] + kpts_outputs: list[np.ndarray] | None = None + masks_outputs_values: list[np.ndarray] | None = None + protos_output: np.ndarray | None = None + protos_len: int | None = None + v26_mask_coeffs: np.ndarray | None = None + v26_protos: np.ndarray | None = None + v26_pose_kpts: np.ndarray | None = None + + +def build_yolo_compute_inputs( + output: EngineOutput, + model_spec: ModelSpec, + *, + class_map: dict[int, str], + subtype: str, + n_classes: int | None = None, + anchors: list[list[list[float]]] | None = None, + strides: list[int] | None = None, + conf_threshold: float, + iou_threshold: float, + max_det: int, + n_keypoints: int | None = None, + mask_conf: float = 0.5, + keypoint_label_names: list[str] | None = None, + keypoint_edges: list[tuple[int, int]] | None = None, +) -> YOLOComputeInputs: + subtype_enum = parse_yolo_subtype(subtype) + tensors = extract_yolo_tensors(output, subtype_enum) + + resolved_strides: list[int] | None = None + if subtype_enum == YOLOSubtype.V26: + resolved_n_classes = n_classes or len(class_map) + else: + resolved_strides = resolve_yolo_strides( + strides, + subtype_enum, + num_outputs=len(tensors.outputs_values), + ) + anchors_array = reshape_anchors(anchors, resolved_strides) + resolved_n_classes = resolve_num_classes( + tensors.outputs_values[0], + anchors_array, + n_classes, + ) + + resolved_n_keypoints = resolve_num_keypoints( + tensors.kpts_outputs, + n_keypoints, + ) + + return YOLOComputeInputs( + subtype=subtype_enum, + layer_names=tensors.layer_names, + outputs_values=tensors.outputs_values, + strides=resolved_strides if subtype_enum != YOLOSubtype.V26 else strides, + conf_threshold=conf_threshold, + n_classes=resolved_n_classes, + iou_threshold=iou_threshold, + max_det=max_det, + anchors=anchors, + n_keypoints=resolved_n_keypoints, + label_names=ordered_class_names(class_map), + keypoint_label_names=keypoint_label_names, + keypoint_edges=keypoint_edges, + input_shape=(model_spec.height, model_spec.width), + kpts_outputs=tensors.kpts_outputs, + masks_outputs_values=tensors.masks_outputs_values, + protos_output=tensors.protos_output, + protos_len=tensors.protos_len, + mask_conf=mask_conf, + v26_mask_coeffs=tensors.v26_mask_coeffs, + v26_protos=tensors.v26_protos, + v26_pose_kpts=tensors.v26_pose_kpts, + ) + + +def build_yolo_instance_masks( + compute_inputs: YOLOComputeInputs, + *, + outputs_values: list[np.ndarray] | None = None, +) -> np.ndarray: + input_shape = compute_inputs.input_shape + if input_shape is None: + raise ValueError("YOLO mask rebuilding requires an input shape.") + height, width = input_shape + + if compute_inputs.subtype == YOLOSubtype.V26: + return _build_yolo26_instance_masks( + compute_inputs, + height=height, + width=width, + ) + + return _build_standard_instance_masks( + compute_inputs, + outputs_values=outputs_values, + height=height, + width=width, + ) + + +def parse_yolo_subtype(subtype: str) -> YOLOSubtype: + try: + return YOLOSubtype(subtype.lower()) + except ValueError as err: + raise ValueError( + f"Invalid YOLO subtype {subtype}. Supported YOLO subtypes are " + f"{[member.value for member in YOLOSubtype][:-1]}." + ) from err + + +def extract_yolo_tensors( + output: EngineOutput, + subtype: YOLOSubtype, +) -> YoloTensorBundle: + layer_names = list(output.names()) + if subtype == YOLOSubtype.V26: + return _extract_yolo26_tensors(output, layer_names) + return _extract_standard_yolo_tensors(output, layer_names, subtype) + + +def _extract_yolo26_tensors( + output: EngineOutput, + layer_names: list[str], +) -> YoloTensorBundle: + if any("output_masks" in name for name in layer_names): + mask_name = next(name for name in layer_names if "output_masks" in name) + protos_name = next( + (name for name in layer_names if "protos" in name), + "protos_output", + ) + return YoloTensorBundle( + layer_names=layer_names, + outputs_values=[ + output.get("output_yolo26").astype(np.float32, copy=False) + ], + v26_mask_coeffs=output.get(mask_name).astype( + np.float32, + copy=False, + ), + v26_protos=output.get(protos_name, layout="NCHW").astype( + np.float32, + copy=False, + )[0], + ) + + if any("kpt_output" in name for name in layer_names): + kpt_name = next(name for name in layer_names if "kpt_output" in name) + return YoloTensorBundle( + layer_names=layer_names, + outputs_values=[ + output.get("output_yolo26").astype(np.float32, copy=False) + ], + v26_pose_kpts=output.get(kpt_name).astype(np.float32, copy=False), + ) + + return YoloTensorBundle( + layer_names=layer_names, + outputs_values=[ + output.get(name).astype(np.float32, copy=False) + for name in layer_names + ], + ) + + +def _extract_standard_yolo_tensors( + output: EngineOutput, + layer_names: list[str], + subtype: YOLOSubtype, +) -> YoloTensorBundle: + outputs_names = sorted( + [name for name in layer_names if "_yolo" in name or "yolo-" in name] + ) + outputs_values = [ + output.get(name, layout="NCHW").astype(np.float32, copy=False) + for name in outputs_names + ] + + if any("kpt_output" in name for name in layer_names) and subtype != YOLOSubtype.P: + kpts_output_names = sorted( + [name for name in layer_names if "kpt_output" in name] + ) + return YoloTensorBundle( + layer_names=layer_names, + outputs_values=outputs_values, + kpts_outputs=[ + output.get(name).astype(np.float32, copy=False) + for name in kpts_output_names + ], + ) + + if any("_masks" in name for name in layer_names) and subtype != YOLOSubtype.P: + protos_name = next( + (name for name in layer_names if "protos" in name), + "protos_output", + ) + mask_output_names = sorted( + [name for name in layer_names if "_masks" in name] + ) + protos_output = output.get(protos_name, layout="NCHW").astype( + np.float32, + copy=False, + ) + return YoloTensorBundle( + layer_names=layer_names, + outputs_values=outputs_values, + masks_outputs_values=[ + output.get(name, layout="NCHW").astype(np.float32, copy=False) + for name in mask_output_names + ], + protos_output=protos_output, + protos_len=protos_output.shape[1], + ) + + return YoloTensorBundle( + layer_names=layer_names, + outputs_values=outputs_values, + ) + + +def reshape_anchors( + anchors: list[list[list[float]]] | None, + strides: list[int], +) -> np.ndarray | None: + if not anchors: + return None + return np.asarray(anchors, dtype=np.float32).reshape(len(strides), -1) + + +def resolve_num_classes( + output_tensor: np.ndarray, + anchors: np.ndarray | None, + configured_n_classes: int | None, +) -> int: + n_anchors_per_head = anchors.shape[1] // 2 if anchors is not None else 1 + inferred_n_classes = ( + output_tensor.shape[1] - 5 + if anchors is None + else (output_tensor.shape[1] // n_anchors_per_head) - 5 + ) + if ( + configured_n_classes is not None + and inferred_n_classes != configured_n_classes + ): + raise ValueError( + f"The provided number of classes {configured_n_classes} does not match the " + f"model's {inferred_n_classes}." + ) + return inferred_n_classes + + +def resolve_num_keypoints( + kpts_outputs: list[np.ndarray] | None, + configured_n_keypoints: int | None, +) -> int: + inferred_n_keypoints = ( + kpts_outputs[0].shape[1] // 3 if kpts_outputs is not None else None + ) + if ( + configured_n_keypoints is not None + and inferred_n_keypoints is not None + and inferred_n_keypoints != configured_n_keypoints + ): + raise ValueError( + f"The provided number of keypoints {configured_n_keypoints} does not match " + f"the model's {inferred_n_keypoints}." + ) + return inferred_n_keypoints or configured_n_keypoints or 17 + + +def _build_yolo26_instance_masks( + compute_inputs: YOLOComputeInputs, + *, + height: int, + width: int, +) -> np.ndarray: + results, mask_coefficients = decode_yolo26( + compute_inputs.outputs_values[0], + compute_inputs.conf_threshold, + compute_inputs.max_det, + extra_raw=compute_inputs.v26_mask_coeffs, + ) + if mask_coefficients is None: + raise ValueError( + "YOLO26 instance segmentation requires mask coefficients." + ) + if compute_inputs.v26_protos is None: + raise ValueError( + "YOLO26 instance segmentation requires prototype masks." + ) + return _refine_instance_masks( + mask_prototypes=compute_inputs.v26_protos, + mask_coefficients=mask_coefficients, + bounding_boxes=results[:, :4], + height=height, + width=width, + ) + + +def _build_standard_instance_masks( + compute_inputs: YOLOComputeInputs, + *, + outputs_values: list[np.ndarray] | None, + height: int, + width: int, +) -> np.ndarray: + resolved_outputs_values = outputs_values or compute_inputs.outputs_values + resolved_strides = resolve_yolo_strides( + compute_inputs.strides, + compute_inputs.subtype, + num_outputs=len(resolved_outputs_values), + ) + anchors_array = reshape_anchors(compute_inputs.anchors, resolved_strides) + + results = decode_yolo_output( + resolved_outputs_values, + resolved_strides, + anchors_array, + conf_thres=compute_inputs.conf_threshold, + iou_thres=compute_inputs.iou_threshold, + num_classes=compute_inputs.n_classes, + det_mode=False, + subtype=compute_inputs.subtype, + ) + + if ( + compute_inputs.protos_output is None + or compute_inputs.masks_outputs_values is None + or compute_inputs.protos_len is None + ): + raise ValueError( + "YOLO instance segmentation requires prototype and mask outputs." + ) + + mask_coefficients = _collect_mask_coefficients( + results[:, 6:], + compute_inputs.masks_outputs_values, + compute_inputs.protos_len, + ) + if mask_coefficients.size == 0: + return np.zeros((0, height, width), dtype=np.uint8) + + return _refine_instance_masks( + mask_prototypes=compute_inputs.protos_output[0], + mask_coefficients=mask_coefficients, + bounding_boxes=results[:, :4], + height=height, + width=width, + ) + + +def _collect_mask_coefficients( + detections_metadata: np.ndarray, + masks_outputs_values: list[np.ndarray], + protos_len: int, +) -> np.ndarray: + if detections_metadata.size == 0: + return np.zeros((0, protos_len), dtype=np.float32) + + mask_coefficients = [ + masks_outputs_values[hi][ + 0, + ai * protos_len : (ai + 1) * protos_len, + yi, + xi, + ] + for hi, ai, xi, yi in detections_metadata.astype(int) + ] + return np.stack(mask_coefficients, axis=0) + + +def _refine_instance_masks( + *, + mask_prototypes: np.ndarray, + mask_coefficients: np.ndarray, + bounding_boxes: np.ndarray, + height: int, + width: int, +) -> np.ndarray: + if mask_coefficients.shape[0] == 0 or bounding_boxes.shape[0] == 0: + return np.zeros((0, height, width), dtype=np.uint8) + + prototypes_tensor = torch.as_tensor(mask_prototypes, dtype=torch.float32) + coefficients_tensor = torch.as_tensor( + mask_coefficients, dtype=torch.float32 + ) + boxes_tensor = torch.as_tensor(bounding_boxes, dtype=torch.float32) + + channels, proto_h, proto_w = prototypes_tensor.shape + masks_combined = ( + coefficients_tensor @ prototypes_tensor.view(channels, -1) + ).view(-1, proto_h, proto_w) + + scaled_boxes = boxes_tensor.clone() + scaled_boxes[:, [0, 2]] *= proto_w / width + scaled_boxes[:, [1, 3]] *= proto_h / height + + cropped_masks = _apply_bounding_box_to_masks(masks_combined, scaled_boxes) + upsampled_masks = F.interpolate( + cropped_masks.unsqueeze(0), + size=(height, width), + mode="bilinear", + align_corners=False, + ).squeeze(0) + + return (upsampled_masks > 0).to(torch.uint8).cpu().numpy() + + +def _apply_bounding_box_to_masks( + masks: torch.Tensor, + bounding_boxes: torch.Tensor, +) -> torch.Tensor: + _, mask_height, mask_width = masks.shape + left, top, right, bottom = torch.split( + bounding_boxes[:, :, None], + 1, + dim=1, + ) + width_indices = torch.arange( + mask_width, + device=masks.device, + dtype=left.dtype, + )[None, None, :] + height_indices = torch.arange( + mask_height, + device=masks.device, + dtype=left.dtype, + )[None, :, None] + + return masks * ( + (width_indices >= left) + & (width_indices < right) + & (height_indices >= top) + & (height_indices < bottom) + ) diff --git a/luxonis_eval/parsers/yolo.py b/luxonis_eval/parsers/yolo.py index 0d41f15..a78b73b 100644 --- a/luxonis_eval/parsers/yolo.py +++ b/luxonis_eval/parsers/yolo.py @@ -2,25 +2,20 @@ import depthai as dai import numpy as np -import torch -import torch.nn.functional as F from depthai_nodes.message.creators import create_detection_message from depthai_nodes.node.parsers.yolo import ( - YOLOComputeInputs, YOLOExtendedParser as DepthAINodesYOLOExtendedParser, ) -from depthai_nodes.node.parsers.utils.yolo import ( - YOLOSubtype, - decode_yolo26, - decode_yolo_output, - resolve_yolo_strides, -) from luxonis_eval.engines.base_engine import ModelSpec from luxonis_eval.engines.io import EngineOutput -from luxonis_eval.utils.utils import ordered_class_names from .base_parser import BaseParser +from .predictions import Prediction +from .utils.yolo import ( + build_yolo_compute_inputs, + build_yolo_instance_masks, +) class YOLOExtendedParser(BaseParser): @@ -51,7 +46,7 @@ def parse( keypoint_label_names: list[str] | None = None, keypoint_edges: list[tuple[int, int]] | None = None, **kwargs: Any, - ) -> dai.ImgDetections: + ) -> Prediction: del kwargs compute_inputs = build_yolo_compute_inputs( output, @@ -78,21 +73,23 @@ def parse( mode = int(payload["mode"]) if mode == self._KPTS_MODE: - return create_detection_message( - bboxes=payload["bboxes"], - scores=payload["scores"], - labels=payload["labels"], - label_names=payload["label_names"], - keypoints=payload["keypoints"], - keypoints_scores=payload["keypoints_scores"], - keypoint_label_names=payload.get( - "keypoint_label_names", - keypoint_label_names, - ), - keypoint_edges=payload.get( - "keypoint_edges", - keypoint_edges, - ), + return Prediction( + detections=create_detection_message( + bboxes=payload["bboxes"], + scores=payload["scores"], + labels=payload["labels"], + label_names=payload["label_names"], + keypoints=payload["keypoints"], + keypoints_scores=payload["keypoints_scores"], + keypoint_label_names=payload.get( + "keypoint_label_names", + keypoint_label_names, + ), + keypoint_edges=payload.get( + "keypoint_edges", + keypoint_edges, + ), + ) ) if mode == self._SEG_MODE: @@ -113,388 +110,16 @@ def parse( f"{len(message.detections)} detections but " f"{instance_masks.shape[0]} rebuilt instance masks." ) - store_prediction_instance_masks(message, instance_masks) - return message - - return create_detection_message( - bboxes=payload["bboxes"], - scores=payload["scores"], - labels=payload["labels"], - label_names=payload["label_names"], - ) - - -prediction_instance_masks_by_id: dict[int, np.ndarray] = {} - - -def store_prediction_instance_masks( - predictions: dai.ImgDetections, instance_masks: np.ndarray -) -> None: - prediction_instance_masks_by_id[id(predictions)] = instance_masks - - -def get_prediction_instance_masks( - predictions: dai.ImgDetections, -) -> np.ndarray | None: - return prediction_instance_masks_by_id.get(id(predictions)) - - -def clear_prediction_metadata(predictions: Any) -> None: - prediction_instance_masks_by_id.pop(id(predictions), None) - - -def build_yolo_compute_inputs( - output: EngineOutput, - model_spec: ModelSpec, - *, - class_map: dict[int, str], - subtype: str, - n_classes: int | None = None, - anchors: list[list[list[float]]] | None = None, - strides: list[int] | None = None, - conf_threshold: float, - iou_threshold: float, - max_det: int, - n_keypoints: int | None = None, - mask_conf: float = 0.5, - keypoint_label_names: list[str] | None = None, - keypoint_edges: list[tuple[int, int]] | None = None, -) -> YOLOComputeInputs: - """Adapter that converts EngineOutput + ModelSpec into the field - mapping required to construct ``depthai_nodes`` - ``YOLOComputeInputs``.""" - try: - subtype_enum = YOLOSubtype(subtype.lower()) - except ValueError as err: - raise ValueError( - f"Invalid YOLO subtype {subtype}. Supported YOLO subtypes are " - f"{[e.value for e in YOLOSubtype][:-1]}." - ) from err - - layer_names = list(output.names()) - outputs_values: list[np.ndarray] - kpts_outputs: list[np.ndarray] | None = None - masks_outputs_values: list[np.ndarray] | None = None - protos_output: np.ndarray | None = None - protos_len: int | None = None - v26_mask_coeffs: np.ndarray | None = None - v26_protos: np.ndarray | None = None - v26_pose_kpts: np.ndarray | None = None - - if subtype_enum == YOLOSubtype.V26: - if any("output_masks" in name for name in layer_names): - outputs_values = [ - output.get("output_yolo26").astype(np.float32, copy=False) - ] - mask_name = next( - name for name in layer_names if "output_masks" in name - ) - protos_name = next( - (name for name in layer_names if "protos" in name), - "protos_output", - ) - v26_mask_coeffs = output.get(mask_name).astype( - np.float32, copy=False - ) - v26_protos = output.get(protos_name, layout="NCHW").astype( - np.float32, copy=False - )[0] - elif any("kpt_output" in name for name in layer_names): - outputs_values = [ - output.get("output_yolo26").astype(np.float32, copy=False) - ] - kpt_name = next( - name for name in layer_names if "kpt_output" in name - ) - v26_pose_kpts = output.get(kpt_name).astype(np.float32, copy=False) - else: - outputs_values = [ - output.get(name).astype(np.float32, copy=False) - for name in layer_names - ] - resolved_n_classes = n_classes or len(class_map) - else: - outputs_names = sorted( - [ - name - for name in layer_names - if "_yolo" in name or "yolo-" in name - ] - ) - outputs_values = [ - output.get(name, layout="NCHW").astype(np.float32, copy=False) - for name in outputs_names - ] - - if ( - any("kpt_output" in name for name in layer_names) - and subtype_enum != YOLOSubtype.P - ): - kpts_output_names = sorted( - [name for name in layer_names if "kpt_output" in name] - ) - kpts_outputs = [ - output.get(name).astype(np.float32, copy=False) - for name in kpts_output_names - ] - elif ( - any("_masks" in name for name in layer_names) - and subtype_enum != YOLOSubtype.P - ): - protos_name = next( - (name for name in layer_names if "protos" in name), - "protos_output", - ) - mask_output_names = sorted( - [name for name in layer_names if "_masks" in name] - ) - masks_outputs_values = [ - output.get(name, layout="NCHW").astype(np.float32, copy=False) - for name in mask_output_names - ] - protos_output = output.get(protos_name, layout="NCHW").astype( - np.float32, copy=False - ) - protos_len = protos_output.shape[1] - - resolved_strides = resolve_yolo_strides( - strides, - subtype_enum, - num_outputs=len(outputs_values), - ) - final_anchors: np.ndarray | None = ( - np.asarray(anchors, dtype=np.float32).reshape( - len(resolved_strides), -1 - ) - if anchors - else None - ) - n_anchors_per_head = ( - final_anchors.shape[1] // 2 - if final_anchors is not None - else 1 - ) - inferred_n_classes = ( - outputs_values[0].shape[1] - 5 - if final_anchors is None - else (outputs_values[0].shape[1] // n_anchors_per_head) - 5 - ) - if n_classes is not None and inferred_n_classes != n_classes: - raise ValueError( - f"The provided number of classes {n_classes} does not match the " - f"model's {inferred_n_classes}." + return Prediction( + detections=message, + instance_masks=instance_masks, ) - resolved_n_classes = inferred_n_classes - - inferred_n_keypoints = ( - kpts_outputs[0].shape[1] // 3 if kpts_outputs is not None else None - ) - if ( - n_keypoints is not None - and inferred_n_keypoints is not None - and inferred_n_keypoints != n_keypoints - ): - raise ValueError( - f"The provided number of keypoints {n_keypoints} does not match " - f"the model's {inferred_n_keypoints}." - ) - resolved_n_keypoints = inferred_n_keypoints or n_keypoints or 17 - - return YOLOComputeInputs( - subtype=subtype_enum, - layer_names=layer_names, - outputs_values=outputs_values, - strides=resolved_strides if subtype_enum != YOLOSubtype.V26 else strides, - conf_threshold=conf_threshold, - n_classes=resolved_n_classes, - iou_threshold=iou_threshold, - max_det=max_det, - anchors=anchors, - n_keypoints=resolved_n_keypoints, - label_names=ordered_class_names(class_map), - keypoint_label_names=keypoint_label_names, - keypoint_edges=keypoint_edges, - input_shape=(model_spec.height, model_spec.width), - kpts_outputs=kpts_outputs, - masks_outputs_values=masks_outputs_values, - protos_output=protos_output, - protos_len=protos_len, - mask_conf=mask_conf, - v26_mask_coeffs=v26_mask_coeffs, - v26_protos=v26_protos, - v26_pose_kpts=v26_pose_kpts, - ) - - -def build_yolo_instance_masks( - compute_inputs: YOLOComputeInputs, - *, - outputs_values: list[np.ndarray] | None = None, -) -> np.ndarray: - """Rebuild per-instance masks with refinement. - - Detection selection still comes from the DepthAI YOLO decode path. - This helper only regenerates instance masks from the kept detections. - - Mirrored LuxonisTrain's - ``luxonis_train.nodes.heads.precision_seg_bbox_head.refine_and_apply_masks()``: - Eval uses the same prototype-combination, bbox-cropping, and - upsampling behavior. - """ - subtype = compute_inputs.subtype - input_shape = compute_inputs.input_shape - if input_shape is None: - raise ValueError("YOLO mask rebuilding requires an input shape.") - height, width = input_shape - - if subtype == YOLOSubtype.V26: - results, mask_coeffs = decode_yolo26( - compute_inputs.outputs_values[0], - compute_inputs.conf_threshold, - compute_inputs.max_det, - extra_raw=compute_inputs.v26_mask_coeffs, - ) - if mask_coeffs is None: - raise ValueError( - "YOLO26 instance segmentation requires mask coefficients." - ) - mask_prototypes = compute_inputs.v26_protos - if mask_prototypes is None: - raise ValueError( - "YOLO26 instance segmentation requires prototype masks." + return Prediction( + detections=create_detection_message( + bboxes=payload["bboxes"], + scores=payload["scores"], + labels=payload["labels"], + label_names=payload["label_names"], ) - return _refine_instance_masks( - mask_prototypes=mask_prototypes, - mask_coefficients=mask_coeffs, - bounding_boxes=results[:, :4], - height=height, - width=width, - ) - - resolved_outputs_values = outputs_values or compute_inputs.outputs_values - - resolved_strides = resolve_yolo_strides( - compute_inputs.strides, - subtype, - num_outputs=len(resolved_outputs_values), - ) - - anchors = compute_inputs.anchors - anchors_array = ( - np.asarray(anchors, dtype=np.float32).reshape( - len(resolved_strides), -1 - ) - if anchors is not None - else None - ) - - results = decode_yolo_output( - resolved_outputs_values, - resolved_strides, - anchors_array, - conf_thres=compute_inputs.conf_threshold, - iou_thres=compute_inputs.iou_threshold, - num_classes=compute_inputs.n_classes, - det_mode=False, - subtype=subtype, - ) - - protos_output = compute_inputs.protos_output - masks_outputs_values = compute_inputs.masks_outputs_values - protos_len = compute_inputs.protos_len - if protos_output is None or masks_outputs_values is None or protos_len is None: - raise ValueError( - "YOLO instance segmentation requires prototype and mask outputs." - ) - - mask_coefficients = [] - for other in results[:, 6:]: - hi, ai, xi, yi = other.astype(int) - mask_coefficients.append( - masks_outputs_values[hi][0, ai * protos_len : (ai + 1) * protos_len, yi, xi] ) - - if not mask_coefficients: - return np.zeros((0, height, width), dtype=np.uint8) - - return _refine_instance_masks( - mask_prototypes=protos_output[0], - mask_coefficients=np.stack(mask_coefficients, axis=0), - bounding_boxes=results[:, :4], - height=height, - width=width, - ) - - -def _refine_instance_masks( - *, - mask_prototypes: np.ndarray, - mask_coefficients: np.ndarray, - bounding_boxes: np.ndarray, - height: int, - width: int, -) -> np.ndarray: - """NumPy/Torch port of LuxonisTrain's mask refinement step. - - This mirrors - ``luxonis_train.nodes.heads.precision_seg_bbox_head.refine_and_apply_masks()`` - because DepthAI returns a merged instance-id mask, while evaluation needs - the per-instance masks before that merge. - """ - if mask_coefficients.shape[0] == 0 or bounding_boxes.shape[0] == 0: - return np.zeros((0, height, width), dtype=np.uint8) - - prototypes_tensor = torch.as_tensor(mask_prototypes, dtype=torch.float32) - coefficients_tensor = torch.as_tensor( - mask_coefficients, dtype=torch.float32 - ) - boxes_tensor = torch.as_tensor(bounding_boxes, dtype=torch.float32) - - channels, proto_h, proto_w = prototypes_tensor.shape - masks_combined = ( - coefficients_tensor @ prototypes_tensor.view(channels, -1) - ).view(-1, proto_h, proto_w) - - scaled_boxes = boxes_tensor.clone() - scaled_boxes[:, [0, 2]] *= proto_w / width - scaled_boxes[:, [1, 3]] *= proto_h / height - - cropped_masks = _apply_bounding_box_to_masks(masks_combined, scaled_boxes) - upsampled_masks = F.interpolate( - cropped_masks.unsqueeze(0), - size=(height, width), - mode="bilinear", - align_corners=False, - ).squeeze(0) - - return (upsampled_masks > 0).to(torch.uint8).cpu().numpy() - - -def _apply_bounding_box_to_masks( - masks: torch.Tensor, - bounding_boxes: torch.Tensor, -) -> torch.Tensor: - """Mirror LuxonisTrain's bbox mask cropping helper. - - This matches - ``luxonis_train.utils.boundingbox.apply_bounding_box_to_masks()`` so the - rebuilt masks follow the same crop semantics. - """ - _, mask_height, mask_width = masks.shape - left, top, right, bottom = torch.split( - bounding_boxes[:, :, None], 1, dim=1 - ) - width_indices = torch.arange( - mask_width, device=masks.device, dtype=left.dtype - )[None, None, :] - height_indices = torch.arange( - mask_height, device=masks.device, dtype=left.dtype - )[None, :, None] - - return masks * ( - (width_indices >= left) - & (width_indices < right) - & (height_indices >= top) - & (height_indices < bottom) - ) diff --git a/luxonis_eval/utils/json_utils.py b/luxonis_eval/utils/json_utils.py index c06f908..e5e3b2b 100644 --- a/luxonis_eval/utils/json_utils.py +++ b/luxonis_eval/utils/json_utils.py @@ -1,11 +1,16 @@ import json +from dataclasses import asdict, is_dataclass from pathlib import Path from typing import Any import numpy as np +from luxonis_eval.core.results import EvaluationResult + def to_jsonable(value: Any) -> Any: + if is_dataclass(value) and not isinstance(value, type): + return to_jsonable(asdict(value)) if isinstance(value, dict): return {str(key): to_jsonable(item) for key, item in value.items()} if isinstance(value, (list, tuple)): @@ -19,20 +24,20 @@ def to_jsonable(value: Any) -> Any: return value -def build_output_json_payload(result: dict[str, Any]) -> dict[str, Any]: +def build_output_json_payload(result: EvaluationResult) -> dict[str, Any]: metrics_payload = [ {"name": metric_name, "values": to_jsonable(metric_values)} - for metric_name, metric_values in result["metrics"] + for metric_name, metric_values in result.metrics.items() ] return { - "engine": result["engine"], - "model_name": result["model_name"], + "engine": result.engine, + "model_name": result.model_name, "metrics": metrics_payload, } -def write_output_json(path: str, result: dict[str, Any]) -> None: +def write_output_json(path: str, result: EvaluationResult) -> None: output_path = Path(path) output_path.parent.mkdir(parents=True, exist_ok=True) payload = build_output_json_payload(result) diff --git a/luxonis_eval/utils/utils.py b/luxonis_eval/utils/utils.py index 3b56ba0..70edcfb 100644 --- a/luxonis_eval/utils/utils.py +++ b/luxonis_eval/utils/utils.py @@ -1,7 +1,6 @@ from pathlib import Path from typing import Any -import depthai as dai import numpy as np import onnxruntime as ort @@ -106,25 +105,6 @@ def ordered_class_names(class_map: dict[int, str]) -> list[str]: return [class_map[index] for index in ordered_indices] -def extract_segmentation_mask(predictions: dai.SegmentationMask) -> np.ndarray: - """Extract a semantic-segmentation mask from a DepthAI message.""" - if hasattr(predictions, "getCvMask"): - mask = predictions.getCvMask() - elif hasattr(predictions, "getCvSegmentationMask"): - mask = predictions.getCvSegmentationMask() - else: - raise TypeError( - "Unsupported segmentation prediction type " - f"{type(predictions)!r}: expected a DepthAI SegmentationMask " - "message." - ) - - if mask is None: - raise ValueError("Segmentation prediction does not contain a mask.") - - return np.asarray(mask) - - def get_onnx_input_info(onnx_path: Path | None) -> dict[str, Any]: """Retrieve ONNX model input information. diff --git a/luxonis_eval/visualizers/base_visualizer.py b/luxonis_eval/visualizers/base_visualizer.py index b5a8d88..0b1b705 100644 --- a/luxonis_eval/visualizers/base_visualizer.py +++ b/luxonis_eval/visualizers/base_visualizer.py @@ -4,9 +4,9 @@ import numpy as np from luxonis_ml.utils.registry import AutoRegisterMeta +from luxonis_eval.parsers.predictions import Prediction from luxonis_eval.registry import VISUALIZERS_REGISTRY - class BaseVisualizer( ABC, metaclass=AutoRegisterMeta, @@ -27,7 +27,7 @@ def __init__(self, **kwargs: Any) -> None: @abstractmethod def visualize( self, - predictions: Any, + predictions: Prediction, vis_frame: np.ndarray, **kwargs: Any, ) -> None: From 62f0bd3e29eee62ac713b1483193b12b8db38f9c Mon Sep 17 00:00:00 2001 From: Dani Rogmans Date: Thu, 13 Aug 2026 12:30:21 +0200 Subject: [PATCH 2/6] readme changes and simplification --- README.md | 121 +++++++++++++++++++++++----------- luxonis_eval/core/__init__.py | 14 +--- 2 files changed, 84 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 61081f6..bf640f6 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,20 @@ ## 🌟 Overview -`LuxonisEval` is a modular evaluation framework for benchmarking neural network models across multiple inference backends. It supports inference on Luxonis devices (`RVC2` and `RVC4`) through `DepthAI`, as well as host-side inference through `ONNX Runtime`, while reporting both quality metrics and throughput or latency performance. +`LuxonisEval` is a modular evaluation framework for benchmarking neural network models across multiple inference engines. It supports on-device inference on Luxonis devices (`RVC2` and `RVC4`) through `DepthAI`, as well as host-side inference through `ONNX Runtime`, while reporting both quality metrics and throughput or latency performance. The framework follows a registry-based architecture: each pluggable component (engines, dataloaders, parsers, metrics, and visualizers) registers itself automatically. This lets you swap, extend, or add parts of the evaluation pipeline without modifying the core evaluation loop. In practice, adding a new component usually means subclassing the appropriate base class and referencing it by name in the configuration. ### ✨ Key Features -- **Multiple Inference Backends** +- **Multiple Inference Engines** - [**DepthAI Engine**](luxonis_eval/engines/depthai_engine.py) - Run models exported as [NNArchive](https://docs.luxonis.com/software-v3/ai-inference/nn-archive) files on Luxonis devices via [DepthAI](https://docs.luxonis.com/software-v3/depthai/) - [**ONNX Engine**](luxonis_eval/engines/onnx_engine.py) - Run models on CPU or GPU using `ONNX Runtime` - **Dataset Loading** - [**LuxonisLoader**](https://github.com/luxonis/luxonis-ml/tree/main/luxonis_ml/data/loaders#luxonisml-loader) - Load datasets stored in Luxonis Data Format (`LDF`) - [**BaseEvalLoader**](luxonis_eval/loaders/base_loader.py) - Base class for custom dataloaders -- **Supported Tasks** - - `Classification` - Image classification - - `Detection` - Bounding box detection - - `SemanticSegmentation` - Per-pixel class labeling - - `InstanceSegmentation` - Per-instance masks with detection - - `KeypointDetection` - Body or object keypoint localization -- **Built-In Metrics** - - [`TopKAccuracy`](luxonis_eval/metrics/topk_accuracy.py) - Top-1 and Top-5 accuracy for classification - - [`BboxMeanAveragePrecision`](luxonis_eval/metrics/bbox_map.py) - COCO-style mAP for bounding box detection - - [`MaskMeanAveragePrecision`](luxonis_eval/metrics/mask_map.py) - COCO-style mAP for instance segmentation - - [`KeypointMeanAveragePrecision`](luxonis_eval/metrics/keypoint_map.py) - OKS-based mAP for keypoint detection - - [`MIoU`](luxonis_eval/metrics/mIoU.py) - Mean Intersection over Union for semantic segmentation - - [`DiceCoefficient`](luxonis_eval/metrics/dice_coef.py) - Dice score for semantic segmentation - - [`ThroughputMetric`](luxonis_eval/metrics/throughput.py) - End-to-end throughput and latency reporting +- **Current Evaluation Coverage** - The built-in parsers and metrics currently cover classification, bounding box detection, semantic segmentation, instance segmentation, and keypoint evaluation +- **NNArchive-Aware Configuration** - Parser metadata and preprocessing hints can be resolved from NNArchive models, including archive-driven overrides when desired - **Extensible Architecture** - Registry-based design powered by [`AutoRegisterMeta`](luxonis_eval/registry.py), making it straightforward to add custom engines, parsers, metrics, loaders, and visualizers @@ -80,6 +68,7 @@ This quickstart runs instance segmentation evaluation with `ONNX Runtime` on CPU - [🎨 Visualizers](#visualizers) - [⚡ Inference Engine](#inference-engine) - [📄 Full Example](#full-example) + - [📏 Metrics](#metrics) - [🏃 Commands](#commands) - [🧱 Extending the Framework](#extending-the-framework) - [📥 Adding a Custom DataLoader](#adding-a-custom-dataloader) @@ -138,7 +127,7 @@ luxonis_eval eval \ --model-path path/to/model.tar.xz \ --backend depthai -# Use the ONNX backend +# Use the ONNX engine luxonis_eval eval \ --config path/to/config.yaml \ --dataset-name coco \ @@ -189,7 +178,7 @@ The repository is organized around a small set of core component types: luxonis_eval/ ├── config/ # Configuration schema and exports ├── core/ # Evaluation lifecycle orchestration -├── engines/ # Inference backends +├── engines/ # Inference engines ├── loaders/ # Dataset loaders ├── metrics/ # Evaluation metrics ├── parsers/ # Model output parsers @@ -212,7 +201,7 @@ All base classes use the [AutoRegisterMeta](https://github.com/luxonis/luxonis-m ### 🔄 Evaluation Pipeline -The evaluation loop in `LuxonisEval.evaluate()` is structured around abstract component interfaces rather than concrete implementations. That design keeps the pipeline modular and makes backend or task-specific components easy to replace. +The evaluation loop in `LuxonisEval.evaluate()` is structured around abstract component interfaces rather than concrete implementations. That design keeps the pipeline modular and makes engine-specific or model-specific components easy to replace. ```bash ┌────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ @@ -230,8 +219,8 @@ The evaluation loop in `LuxonisEval.evaluate()` is structured around abstract co The pipeline works as follows: 1. **DataLoader** provides images together with ground-truth annotations. -2. **Engine** runs inference and returns raw backend outputs. -3. **Parser** converts raw outputs into a structured prediction format. +2. **Engine** runs inference and returns raw engine outputs. +3. **Parser** converts raw outputs into a structured `Prediction` payload. 4. **Metrics** accumulate per-sample results and compute final scores. 5. **Visualizer** optionally renders predictions for inspection. @@ -300,15 +289,41 @@ pipeline: keep_aspect_ratio: true # Preserve aspect ratio during resize ``` +`preprocessing` is resolved before evaluation starts. For ordinary configs, the loader values come directly from YAML. When the model path points to an NNArchive, LuxonisEval can also derive preprocessing hints from the archive metadata. + +```yaml +runtime: + nn_archive_params_override: true +pipeline: + loader: + preprocessing: + keep_aspect_ratio: true + normalize: + active: false + color_space: RGB +``` + +When `runtime.nn_archive_params_override` is `true`, NNArchive metadata takes precedence for: + +- `loader.preprocessing.normalize` +- `loader.preprocessing.color_space` +- evaluator parser selection and parser params +- evaluator `outputs` + +When it is `false`, explicit YAML values stay primary and archive metadata is only used as a fallback. + +> [!IMPORTANT] +> `keep_aspect_ratio` is not inferred from NNArchive metadata. Set it explicitly when your preprocessing depends on preserving aspect ratio or letterboxing. + > [!NOTE] -> When using the `depthai` backend, normalization is usually handled by the model's own preprocessing pipeline. The engine will warn you if normalization is enabled together with `DepthAI`. `DepthAI` also expects `BGR` color space, so a warning is emitted if `RGB` is selected. +> For the `depthai` engine, host-side normalization is skipped because preprocessing is expected to run on-device through the NNArchive pipeline. For the `onnx` engine, resolved normalization stays on the host side. > [!IMPORTANT] -> `LuxonisLoader` evaluation is currently single-task only. Task selection is done by collecting tasks defined at `pipeline.evaluators[*].task_name`. For datasets that use the default empty Luxonis task, set `task_name: ""`. +> `LuxonisLoader` evaluation is currently single-evaluator and single-dataset-task only. `pipeline.evaluators[*].task_name` selects the Luxonis dataset task namespace to evaluate. It is not a framework-level task enum or abstraction. For datasets that use the default empty Luxonis task, set `task_name: ""`. ### 🧠 Evaluators -Each pipeline evaluator binds together the dataset task, parser, metrics, and optional visualizers for one quality-evaluation unit. +Each pipeline evaluator binds together one dataset task selection, one parser, a set of metrics, and optional visualizers for one quality-evaluation unit. ```yaml pipeline: @@ -332,11 +347,16 @@ pipeline: visualizers: [] ``` -- `task_name` selects the Luxonis dataset task evaluated by this entry. If not set we try to automatically infer it. +- `task_name` selects the Luxonis dataset task evaluated by this entry. If not set we try to infer it from the dataset metadata. - `name` is optional and defaults to `task_name` or a stable fallback when `task_name` is empty. - `outputs` is optional in the current single-evaluator implementation; when omitted, the evaluator consumes all engine outputs. - Only zero or one evaluator is currently supported at runtime. Multiple evaluators are rejected with a clear not-yet-implemented error. +Compatibility is driven by data shape, not by a separate task abstraction: + +- the loader must expose the annotation keys required by the configured metrics +- the parser must populate the `Prediction` fields those metrics consume + ### 🎨 Visualizers Visualizers are evaluator-local and plural: @@ -358,7 +378,7 @@ pipeline: ### ⚡ Inference Engine -The engine section selects the backend and points to the model file. Configuration validation ensures that the model format matches the backend (`.tar.xz` for `depthai`, `.onnx` for `onnx`). +The engine section selects the inference engine and points to the model file. Configuration validation ensures that the model format matches the selected engine (`.tar.xz` NNArchive for `depthai` or `onnx`, `.onnx` for `onnx`). ```yaml pipeline: @@ -368,9 +388,15 @@ pipeline: params: {} # Engine-specific parameters, for example device_ip for RVC4 ``` +> [!NOTE] +> The CLI override flag is named `--backend` for convenience, but it simply overrides `pipeline.engine.name`. + ### 📄 Full Example ```yaml +runtime: + nn_archive_params_override: false + pipeline: loader: name: LuxonisLoader @@ -412,11 +438,30 @@ pipeline: visualizers: [] ``` +### 📏 Metrics + +Quality metrics are configured per evaluator under `pipeline.evaluators[*].metrics`. + +| Metric | Typical use | Required target keys | +| --- | --- | --- | +| [`TopKAccuracy`](luxonis_eval/metrics/topk_accuracy.py) | Classification | `["/classification"]` | +| [`BboxMeanAveragePrecision`](luxonis_eval/metrics/bbox_map.py) | Bounding box detection | `["/boundingbox"]` | +| [`MaskMeanAveragePrecision`](luxonis_eval/metrics/mask_map.py) | Instance segmentation | `["/boundingbox", "/instance_segmentation"]` | +| [`KeypointMeanAveragePrecision`](luxonis_eval/metrics/keypoint_map.py) | Keypoint evaluation | `["/boundingbox", "/keypoints"]` | +| [`MIoU`](luxonis_eval/metrics/mIoU.py) | Semantic segmentation | `["/segmentation"]` | +| [`DiceCoefficient`](luxonis_eval/metrics/dice_coef.py) | Semantic segmentation | `["/segmentation"]` | +| [`F1Score`](luxonis_eval/metrics/f1_score.py) | Semantic segmentation | `["/segmentation"]` | +| [`JaccardIndex`](luxonis_eval/metrics/jaccard_index.py) | Semantic segmentation | `["/segmentation"]` | + +Metrics consume parser outputs through the shared [`Prediction`](luxonis_eval/parsers/predictions.py) container. Each metric validates that the parser populated the fields it needs, for example `classification`, `segmentation_mask`, `detections`, or `instance_masks`. + +`ThroughputMetric` is not configured manually in the evaluator list. It is always collected internally and reported alongside the quality metrics in the final `EvaluationResult`. + ### 🏃 Commands -`luxonis-eval eval --config ...` runs the configured quality pipeline in this phase. +`luxonis_eval eval --config ...` runs the configured quality pipeline in this phase. -`luxonis-eval quality --config ...` is a quality-only alias with the same override flags as `eval`. +`luxonis_eval quality --config ...` is a quality-only alias with the same override flags as `eval`. Benchmark configuration can be present in the YAML for future compatibility, but benchmark execution is intentionally not implemented yet. @@ -455,10 +500,10 @@ For `LuxonisLoader`-backed datasets, the LDF and native class maps may differ wh Subclass [`BaseEngine`](luxonis_eval/engines/base_engine.py) and implement the four abstract methods: -- **`setup()`** - Initialize backend resources such as runtimes, sessions, or device connections, then return a `ModelSpec(width, height)` for the loaded model. Keep this idempotent so repeated calls are safe. -- **`infer_once(img)`** - Run inference on a single preprocessed image and return the raw backend output +- **`setup()`** - Initialize engine resources such as runtimes, sessions, or device connections, then return a `ModelSpec(width, height)` for the loaded model. Keep this idempotent so repeated calls are safe. +- **`infer_once(img)`** - Run inference on a single preprocessed image and return the raw engine output - **`vis_frame()`** - Return a copy of the input image suitable for visualization overlays -- **`close()`** - Release backend resources after evaluation finishes +- **`close()`** - Release engine resources after evaluation finishes The framework consumes the returned `ModelSpec` to configure loader preprocessing and metric contexts. @@ -466,16 +511,16 @@ The framework consumes the returned `ModelSpec` to configure loader preprocessin Subclass [`BaseParser`](luxonis_eval/parsers/base_parser.py) and implement the single abstract method: -- **`parse(raw_output, **kwargs)`** - Convert raw backend output into a structured prediction format +- **`parse(raw_output, **kwargs)`** - Convert raw engine output into a structured prediction format -The parser bridges the gap between model-specific tensor layouts and the standardized message types that downstream metrics expect. The built-in parsers produce the following output types: +The parser bridges the gap between model-specific tensor layouts and the standardized prediction fields that downstream metrics expect. The built-in parsers populate [`Prediction`](luxonis_eval/parsers/predictions.py) as follows: -- [**ClassificationParser**](luxonis_eval/parsers/classification.py) -> [depthai_nodes.Classifications](https://github.com/luxonis/depthai-nodes/tree/main/depthai_nodes/message#classifications) -- [**YOLOExtendedParser**](luxonis_eval/parsers/yolo.py) -> [dai.ImgDetections](https://docs.luxonis.com/software-v3/depthai/api/cpp/#classdai_1_1ImgDetections) -- [**SegmentationParser**](luxonis_eval/parsers/segmentation.py) -> [depthai_nodes.SegmentationMask](https://github.com/luxonis/depthai-nodes/tree/main/depthai_nodes/message#segmentationmask) +- [**ClassificationParser**](luxonis_eval/parsers/classification.py) -> `Prediction.classification` +- [**YOLOExtendedParser**](luxonis_eval/parsers/yolo.py) -> `Prediction.detections`, plus `Prediction.instance_masks` for instance segmentation +- [**SegmentationParser**](luxonis_eval/parsers/segmentation.py) -> `Prediction.segmentation_mask` > [!IMPORTANT] -> The parser must produce outputs that the configured metrics can consume. For example, if a metric expects `dai.ImgDetections`, the parser must return that message type. +> The parser must populate the prediction fields that the configured metrics consume. For example, detection metrics require `Prediction.detections`, while instance-segmentation metrics require both `Prediction.detections` and `Prediction.instance_masks`. ### 📐 Adding a Custom Metric @@ -487,7 +532,7 @@ Subclass [`BaseMetric`](luxonis_eval/metrics/base_metric.py) and implement the f - **`compute()`** - Return the final metric values > [!IMPORTANT] -> Metrics must be compatible with the outputs generated by the configured parser. If the parser returns `dai.ImgDetections`, the metric must know how to process that object. +> Metrics must be compatible with the outputs generated by the configured parser. In practice that means the parser must populate the `Prediction` fields that the metric validates in `update()`. ### 🪜 General Pattern diff --git a/luxonis_eval/core/__init__.py b/luxonis_eval/core/__init__.py index 5a521f8..a2778cd 100644 --- a/luxonis_eval/core/__init__.py +++ b/luxonis_eval/core/__init__.py @@ -1,5 +1,4 @@ -from typing import TYPE_CHECKING - +from .core import LuxonisEval from .results import ( EvaluationResult, MetricsResult, @@ -7,17 +6,6 @@ ThroughputResult, ) -if TYPE_CHECKING: - from .core import LuxonisEval - - -def __getattr__(name: str) -> object: - if name == "LuxonisEval": - from .core import LuxonisEval - - return LuxonisEval - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - __all__ = [ "EvaluationResult", "LuxonisEval", From bc7803eff22d6bbbca9b445794a3b38c10ef54dc Mon Sep 17 00:00:00 2001 From: Dani Rogmans Date: Fri, 14 Aug 2026 14:04:22 +0200 Subject: [PATCH 3/6] cleanup, remove redundant checks and unnecessary defensive behavior, less naive counting of number of classes using background --- luxonis_eval/core/reporting.py | 32 ++++++++++------ luxonis_eval/metrics/dice_coef.py | 1 - luxonis_eval/metrics/f1_score.py | 1 - luxonis_eval/metrics/jaccard_index.py | 1 - luxonis_eval/metrics/mIoU.py | 1 - luxonis_eval/metrics/utils/segmentation.py | 34 ++++++++++++----- luxonis_eval/parsers/predictions.py | 43 ++++++++++++---------- 7 files changed, 68 insertions(+), 45 deletions(-) diff --git a/luxonis_eval/core/reporting.py b/luxonis_eval/core/reporting.py index aa9bc00..4ebb45c 100644 --- a/luxonis_eval/core/reporting.py +++ b/luxonis_eval/core/reporting.py @@ -84,13 +84,14 @@ def section( return [[centered, ""]] -def format_evaluation_result(result: EvaluationResult) -> str: - def format_stage(name: str, tp: ThroughputResult) -> str: - ms = float(getattr(tp, f"{name}_ms_per_sample")) - total = float(tp.ms_per_sample) - pct = (ms / total * 100.0) if total else 0.0 - return f"{ms:5.2f} ms | {pct:4.1f}%" +def _format_stage_latency(name: str, throughput: ThroughputResult) -> str: + ms = float(getattr(throughput, f"{name}_ms_per_sample")) + total = float(throughput.ms_per_sample) + pct = (ms / total * 100.0) if total else 0.0 + return f"{ms:5.2f} ms | {pct:4.1f}%" + +def format_evaluation_result(result: EvaluationResult) -> str: rows: list[list[str]] = [] rows += section("SETTINGS") @@ -113,11 +114,20 @@ def format_stage(name: str, tp: ThroughputResult) -> str: rows += section("STAGE BREAKDOWN", line_char="-") rows += [ - ["Inference", format_stage("inference", result.throughput)], - ["Parsing", format_stage("parsing", result.throughput)], - ["Metric Update", format_stage("metric_update", result.throughput)], - ["Metric Compute", format_stage("metric_compute", result.throughput)], - ["Pipeline Overhead", format_stage("overhead", result.throughput)], + ["Inference", _format_stage_latency("inference", result.throughput)], + ["Parsing", _format_stage_latency("parsing", result.throughput)], + [ + "Metric Update", + _format_stage_latency("metric_update", result.throughput), + ], + [ + "Metric Compute", + _format_stage_latency("metric_compute", result.throughput), + ], + [ + "Pipeline Overhead", + _format_stage_latency("overhead", result.throughput), + ], ] rows += section("QUALITY") diff --git a/luxonis_eval/metrics/dice_coef.py b/luxonis_eval/metrics/dice_coef.py index fda047a..9e4131f 100644 --- a/luxonis_eval/metrics/dice_coef.py +++ b/luxonis_eval/metrics/dice_coef.py @@ -83,7 +83,6 @@ def update( predictions, target, include_background=self.include_background, - target_key=self.required_target_keys()[0], target_bg=kwargs.get("target_bg"), class_index_map=kwargs.get("class_index_map"), ) diff --git a/luxonis_eval/metrics/f1_score.py b/luxonis_eval/metrics/f1_score.py index af8c9a1..201f54b 100644 --- a/luxonis_eval/metrics/f1_score.py +++ b/luxonis_eval/metrics/f1_score.py @@ -52,7 +52,6 @@ def update( predictions, target, include_background=self.include_background, - target_key=self.required_target_keys()[0], target_bg=kwargs.get("target_bg"), class_index_map=kwargs.get("class_index_map"), ) diff --git a/luxonis_eval/metrics/jaccard_index.py b/luxonis_eval/metrics/jaccard_index.py index af04b17..a8c186d 100644 --- a/luxonis_eval/metrics/jaccard_index.py +++ b/luxonis_eval/metrics/jaccard_index.py @@ -51,7 +51,6 @@ def update( predictions, target, include_background=self.include_background, - target_key=self.required_target_keys()[0], target_bg=kwargs.get("target_bg"), class_index_map=kwargs.get("class_index_map"), ) diff --git a/luxonis_eval/metrics/mIoU.py b/luxonis_eval/metrics/mIoU.py index a689158..5f710c4 100644 --- a/luxonis_eval/metrics/mIoU.py +++ b/luxonis_eval/metrics/mIoU.py @@ -84,7 +84,6 @@ def update( predictions, target, include_background=self.include_background, - target_key=self.required_target_keys()[0], target_bg=kwargs.get("target_bg"), class_index_map=kwargs.get("class_index_map"), ) diff --git a/luxonis_eval/metrics/utils/segmentation.py b/luxonis_eval/metrics/utils/segmentation.py index 46fe981..a5d6dee 100644 --- a/luxonis_eval/metrics/utils/segmentation.py +++ b/luxonis_eval/metrics/utils/segmentation.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Mapping +from typing import Iterable, Mapping import depthai as dai import numpy as np @@ -26,12 +26,9 @@ class PreparedSegmentationData: def extract_segmentation_mask( - predictions: Prediction | dai.SegmentationMask, + predictions: dai.SegmentationMask, ) -> np.ndarray: """Extract a semantic-segmentation mask from a prediction payload.""" - if isinstance(predictions, Prediction): - predictions = predictions.require_segmentation_mask() - if hasattr(predictions, "getCvMask"): mask = predictions.getCvMask() elif hasattr(predictions, "getCvSegmentationMask"): @@ -54,16 +51,15 @@ def prepare_segmentation_metric_inputs( target: dict[str, np.ndarray], *, include_background: bool, - target_key: str = "/segmentation", target_bg: int | None = None, class_index_map: dict[int, int] | None = None, ) -> PreparedSegmentationData: """Normalize segmentation predictions and targets for metric updates.""" target_mask, binary_target = target_segmentation_to_index_mask( - target[target_key] + target["/segmentation"] ) pred_mask = normalize_prediction_segmentation_mask( - extract_segmentation_mask(predictions), + extract_segmentation_mask(predictions.require_segmentation_mask()), binary_target=binary_target, ) @@ -98,8 +94,26 @@ def infer_num_classes( if configured_num_classes is not None: return configured_num_classes if target_class_map: - return len(target_class_map) - return int(target_tensor.max().item()) + 1 + return _infer_num_classes_from_ids(target_class_map) + return _infer_num_classes_from_ids( + int(class_id) for class_id in torch.unique(target_tensor).tolist() + ) + + +def _infer_num_classes_from_ids(class_ids: Iterable[int]) -> int: + ids = sorted({int(class_id) for class_id in class_ids}) + if not ids: + raise ValueError( + "Cannot infer num_classes from an empty class-id set." + ) + + if ids != list(range(ids[0], ids[-1] + 1)): + raise ValueError( + "Cannot infer num_classes from sparse class ids. Configure " + "`num_classes` explicitly." + ) + + return ids[-1] + 1 def format_torchmetric_result( diff --git a/luxonis_eval/parsers/predictions.py b/luxonis_eval/parsers/predictions.py index ed425a6..bc126ff 100644 --- a/luxonis_eval/parsers/predictions.py +++ b/luxonis_eval/parsers/predictions.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any, cast import depthai as dai import numpy as np @@ -14,33 +15,35 @@ class Prediction: detections: dai.ImgDetections | None = None instance_masks: np.ndarray | None = None + def _require_field(self, field_name: str, description: str) -> Any: + value = getattr(self, field_name) + if value is None: + raise TypeError(f"Prediction does not contain {description}.") + return value + def require_classification(self) -> Classifications: - if self.classification is None: - raise TypeError( - "Prediction does not contain classification scores." - ) - return self.classification + return cast( + Classifications, + self._require_field("classification", "classification scores"), + ) def require_segmentation_mask(self) -> dai.SegmentationMask: - if self.segmentation_mask is None: - raise TypeError( - "Prediction does not contain a segmentation mask." - ) - return self.segmentation_mask + return cast( + dai.SegmentationMask, + self._require_field("segmentation_mask", "a segmentation mask"), + ) def require_detections(self) -> dai.ImgDetections: - if self.detections is None: - raise TypeError( - "Prediction does not contain detections." - ) - return self.detections + return cast( + dai.ImgDetections, + self._require_field("detections", "detections"), + ) def require_instance_masks(self) -> np.ndarray: - if self.instance_masks is None: - raise TypeError( - "Prediction does not contain per-instance masks." - ) - return self.instance_masks + return cast( + np.ndarray, + self._require_field("instance_masks", "per-instance masks"), + ) @property def classes(self) -> list[str]: From c2107358a80415ce35a23976f689e90c258c8d27 Mon Sep 17 00:00:00 2001 From: Dani Rogmans Date: Fri, 14 Aug 2026 14:53:37 +0200 Subject: [PATCH 4/6] restore metrics being keyed by name and value to allow multiple TopKAccuracy with different k --- luxonis_eval/__init__.py | 1 + luxonis_eval/core/__init__.py | 2 ++ luxonis_eval/core/core.py | 6 +++--- luxonis_eval/core/reporting.py | 2 +- luxonis_eval/core/results.py | 3 ++- luxonis_eval/metrics/mask_map.py | 8 +------- luxonis_eval/utils/json_utils.py | 2 +- 7 files changed, 11 insertions(+), 13 deletions(-) diff --git a/luxonis_eval/__init__.py b/luxonis_eval/__init__.py index 1f03681..9c050ec 100644 --- a/luxonis_eval/__init__.py +++ b/luxonis_eval/__init__.py @@ -9,6 +9,7 @@ from .core import ( EvaluationResult, LuxonisEval, + MetricResult, MetricsResult, MetricValues, ThroughputResult, diff --git a/luxonis_eval/core/__init__.py b/luxonis_eval/core/__init__.py index a2778cd..bc1c291 100644 --- a/luxonis_eval/core/__init__.py +++ b/luxonis_eval/core/__init__.py @@ -1,6 +1,7 @@ from .core import LuxonisEval from .results import ( EvaluationResult, + MetricResult, MetricsResult, MetricValues, ThroughputResult, @@ -9,6 +10,7 @@ __all__ = [ "EvaluationResult", "LuxonisEval", + "MetricResult", "MetricsResult", "MetricValues", "ThroughputResult", diff --git a/luxonis_eval/core/core.py b/luxonis_eval/core/core.py index b9f77d6..c0d6326 100644 --- a/luxonis_eval/core/core.py +++ b/luxonis_eval/core/core.py @@ -214,10 +214,10 @@ def _run_evaluators(self) -> EvaluationResult: progress.update(advance=1) metric_compute_t0 = time.perf_counter() - results = { - metric.__class__.__name__: metric.compute() + results = [ + (metric.__class__.__name__, metric.compute()) for metric in self.metrics - } + ] metric_compute_elapsed = time.perf_counter() - metric_compute_t0 throughput = self.throughput_metric.compute( metric_compute=metric_compute_elapsed diff --git a/luxonis_eval/core/reporting.py b/luxonis_eval/core/reporting.py index 4ebb45c..7b60813 100644 --- a/luxonis_eval/core/reporting.py +++ b/luxonis_eval/core/reporting.py @@ -131,7 +131,7 @@ def format_evaluation_result(result: EvaluationResult) -> str: ] rows += section("QUALITY") - for metric_name, metric_values in result.metrics.items(): + for metric_name, metric_values in result.metrics: rows += section(metric_name, line_char="-") for k, v in metric_values.items(): val = f"{v * 100:.2f}%" if isinstance(v, float) else str(v) diff --git a/luxonis_eval/core/results.py b/luxonis_eval/core/results.py index 07dd4c0..023af97 100644 --- a/luxonis_eval/core/results.py +++ b/luxonis_eval/core/results.py @@ -2,7 +2,8 @@ MetricValues = dict[str, float] -MetricsResult = dict[str, MetricValues] +MetricResult = tuple[str, MetricValues] +MetricsResult = list[MetricResult] @dataclass(slots=True) diff --git a/luxonis_eval/metrics/mask_map.py b/luxonis_eval/metrics/mask_map.py index fb55cbd..d2d3f83 100644 --- a/luxonis_eval/metrics/mask_map.py +++ b/luxonis_eval/metrics/mask_map.py @@ -164,18 +164,12 @@ def compute(self) -> dict[str, float]: @staticmethod def _resolve_prediction_instance_masks( - raw_masks: np.ndarray | None, + raw_masks: np.ndarray, *, n_detections: int, height: int, width: int, ) -> np.ndarray: - if raw_masks is None: - raise ValueError( - "MaskMeanAveragePrecision requires raw per-instance masks " - "for the prediction message." - ) - masks = np.asarray(raw_masks) if masks.size == 0: if n_detections != 0: diff --git a/luxonis_eval/utils/json_utils.py b/luxonis_eval/utils/json_utils.py index e5e3b2b..55f420b 100644 --- a/luxonis_eval/utils/json_utils.py +++ b/luxonis_eval/utils/json_utils.py @@ -27,7 +27,7 @@ def to_jsonable(value: Any) -> Any: def build_output_json_payload(result: EvaluationResult) -> dict[str, Any]: metrics_payload = [ {"name": metric_name, "values": to_jsonable(metric_values)} - for metric_name, metric_values in result.metrics.items() + for metric_name, metric_values in result.metrics ] return { From 4da1f51b57774051b2ac96fcb212960c852eacb6 Mon Sep 17 00:00:00 2001 From: Dani Rogmans Date: Mon, 17 Aug 2026 12:48:55 +0200 Subject: [PATCH 5/6] revert logic infer num classes --- luxonis_eval/metrics/utils/segmentation.py | 26 +++++----------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/luxonis_eval/metrics/utils/segmentation.py b/luxonis_eval/metrics/utils/segmentation.py index a5d6dee..7091f02 100644 --- a/luxonis_eval/metrics/utils/segmentation.py +++ b/luxonis_eval/metrics/utils/segmentation.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Iterable, Mapping +from typing import Mapping import depthai as dai import numpy as np @@ -94,26 +94,12 @@ def infer_num_classes( if configured_num_classes is not None: return configured_num_classes if target_class_map: - return _infer_num_classes_from_ids(target_class_map) - return _infer_num_classes_from_ids( - int(class_id) for class_id in torch.unique(target_tensor).tolist() - ) - - -def _infer_num_classes_from_ids(class_ids: Iterable[int]) -> int: - ids = sorted({int(class_id) for class_id in class_ids}) - if not ids: - raise ValueError( - "Cannot infer num_classes from an empty class-id set." - ) - - if ids != list(range(ids[0], ids[-1] + 1)): - raise ValueError( - "Cannot infer num_classes from sparse class ids. Configure " - "`num_classes` explicitly." - ) + return len(target_class_map) - return ids[-1] + 1 + unique_ids = torch.unique(target_tensor) + if unique_ids.numel() == 0: + raise ValueError("Cannot infer num_classes from an empty target.") + return int(unique_ids.max().item()) + 1 def format_torchmetric_result( From b6ea98ba07b0abfc842c9d536d5497cf69beb4d8 Mon Sep 17 00:00:00 2001 From: Dani Rogmans Date: Mon, 17 Aug 2026 13:51:33 +0200 Subject: [PATCH 6/6] remove visualizer mention for now --- README.md | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bf640f6..229a4c5 100644 --- a/README.md +++ b/README.md @@ -359,22 +359,9 @@ Compatibility is driven by data shape, not by a separate task abstraction: ### 🎨 Visualizers -Visualizers are evaluator-local and plural: - -```yaml -pipeline: - evaluators: - - task_name: detection - parser: ... - metrics: - - name: BboxMeanAveragePrecision - params: - iou_type: bbox - visualizers: - - name: InstanceSegmentationVisualizer - active: true - params: {} -``` +Visualizers are evaluator-local and plural. The repository currently ships +only the `BaseVisualizer` interface, so visualizer entries are only useful +when you provide and import a custom implementation. ### ⚡ Inference Engine