1289 lines
56 KiB
Python
Executable File
1289 lines
56 KiB
Python
Executable File
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import random
|
||
from copy import copy
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import numpy as np
|
||
import torch
|
||
import torch.distributed as dist
|
||
import torch.nn as nn
|
||
|
||
from ultralytics.data import build_dataloader, build_yolo_dataset
|
||
from ultralytics.engine.trainer import BaseTrainer
|
||
from ultralytics.models import yolo
|
||
from ultralytics.models.yolo.detect.val import DetectionValidator
|
||
from ultralytics.nn.tasks import DetectionModel
|
||
from ultralytics.utils import DEFAULT_CFG, LOGGER, RANK
|
||
from ultralytics.utils.patches import override_configs
|
||
from ultralytics.utils.plotting import plot_images, plot_labels
|
||
from ultralytics.utils.torch_utils import intersect_dicts, torch_distributed_zero_first, unwrap_model
|
||
|
||
|
||
class GroundDetectionModel(DetectionModel):
|
||
"""YOLO detection model with ground loss for 2D detection tasks.
|
||
|
||
This model extends DetectionModel to use ground-specific loss functions
|
||
that handle difficulty weighting for ground 2D detection.
|
||
"""
|
||
|
||
def init_criterion(self):
|
||
"""Initialize the loss criterion for ground detection.
|
||
|
||
Returns:
|
||
E2EGroundLoss or v8DetectionLossGround depending on model configuration.
|
||
"""
|
||
from ultralytics.utils.loss import E2EGroundLoss, v8DetectionLossGround
|
||
|
||
return E2EGroundLoss(self) if getattr(self, "end2end", False) else v8DetectionLossGround(self)
|
||
|
||
|
||
class DetectionTrainer(BaseTrainer):
|
||
"""A class extending the BaseTrainer class for training based on a detection model.
|
||
|
||
This trainer specializes in object detection tasks, handling the specific requirements for training YOLO models for
|
||
object detection including dataset building, data loading, preprocessing, and model configuration.
|
||
|
||
Attributes:
|
||
model (DetectionModel): The YOLO detection model being trained.
|
||
data (dict): Dictionary containing dataset information including class names and number of classes.
|
||
loss_names (tuple): Names of the loss components used in training (box_loss, cls_loss, dfl_loss).
|
||
|
||
Methods:
|
||
build_dataset: Build YOLO dataset for training or validation.
|
||
get_dataloader: Construct and return dataloader for the specified mode.
|
||
preprocess_batch: Preprocess a batch of images by scaling and converting to float.
|
||
set_model_attributes: Set model attributes based on dataset information.
|
||
get_model: Return a YOLO detection model.
|
||
get_validator: Return a validator for model evaluation.
|
||
label_loss_items: Return a loss dictionary with labeled training loss items.
|
||
progress_string: Return a formatted string of training progress.
|
||
plot_training_samples: Plot training samples with their annotations.
|
||
plot_training_labels: Create a labeled training plot of the YOLO model.
|
||
auto_batch: Calculate optimal batch size based on model memory requirements.
|
||
|
||
Examples:
|
||
>>> from ultralytics.models.yolo.detect import DetectionTrainer
|
||
>>> args = dict(model="yolo26n.pt", data="coco8.yaml", epochs=3)
|
||
>>> trainer = DetectionTrainer(overrides=args)
|
||
>>> trainer.train()
|
||
"""
|
||
|
||
def __init__(self, cfg=DEFAULT_CFG, overrides: dict[str, Any] | None = None, _callbacks=None):
|
||
"""Initialize a DetectionTrainer object for training YOLO object detection models.
|
||
|
||
Args:
|
||
cfg (dict, optional): Default configuration dictionary containing training parameters.
|
||
overrides (dict, optional): Dictionary of parameter overrides for the default configuration.
|
||
_callbacks (list, optional): List of callback functions to be executed during training.
|
||
"""
|
||
super().__init__(cfg, overrides, _callbacks)
|
||
|
||
def build_dataset(self, img_path: str, mode: str = "train", batch: int | None = None):
|
||
"""Build YOLO Dataset for training or validation.
|
||
|
||
Args:
|
||
img_path (str): Path to the folder containing images.
|
||
mode (str): 'train' mode or 'val' mode, users are able to customize different augmentations for each mode.
|
||
batch (int, optional): Size of batches, this is for 'rect' mode.
|
||
|
||
Returns:
|
||
(Dataset): YOLO dataset object configured for the specified mode.
|
||
"""
|
||
gs = max(int(unwrap_model(self.model).stride.max()), 32)
|
||
return build_yolo_dataset(self.args, img_path, batch, self.data, mode=mode, rect=mode == "val", stride=gs)
|
||
|
||
def get_dataloader(self, dataset_path: str, batch_size: int = 16, rank: int = 0, mode: str = "train"):
|
||
"""Construct and return dataloader for the specified mode.
|
||
|
||
Args:
|
||
dataset_path (str): Path to the dataset.
|
||
batch_size (int): Number of images per batch.
|
||
rank (int): Process rank for distributed training.
|
||
mode (str): 'train' for training dataloader, 'val' for validation dataloader.
|
||
|
||
Returns:
|
||
(DataLoader): PyTorch dataloader object.
|
||
"""
|
||
assert mode in {"train", "val"}, f"Mode must be 'train' or 'val', not {mode}."
|
||
with torch_distributed_zero_first(rank): # init dataset *.cache only once if DDP
|
||
dataset = self.build_dataset(dataset_path, mode, batch_size)
|
||
shuffle = mode == "train"
|
||
if getattr(dataset, "rect", False) and shuffle and not np.all(dataset.batch_shapes == dataset.batch_shapes[0]):
|
||
LOGGER.warning("'rect=True' is incompatible with DataLoader shuffle, setting shuffle=False")
|
||
shuffle = False
|
||
return build_dataloader(
|
||
dataset,
|
||
batch=batch_size,
|
||
workers=self.args.workers if mode == "train" else self.args.workers * 2,
|
||
shuffle=shuffle,
|
||
rank=rank,
|
||
drop_last=self.args.compile and mode == "train",
|
||
)
|
||
|
||
def preprocess_batch(self, batch: dict) -> dict:
|
||
"""Preprocess a batch of images by scaling and converting to float.
|
||
|
||
Args:
|
||
batch (dict): Dictionary containing batch data with 'img' tensor.
|
||
|
||
Returns:
|
||
(dict): Preprocessed batch with normalized images.
|
||
"""
|
||
for k, v in batch.items():
|
||
if isinstance(v, torch.Tensor):
|
||
batch[k] = v.to(self.device, non_blocking=self.device.type == "cuda")
|
||
batch["img"] = batch["img"].float() / 255
|
||
if self.args.multi_scale > 0.0:
|
||
imgs = batch["img"]
|
||
sz = (
|
||
random.randrange(
|
||
int(self.args.imgsz * (1.0 - self.args.multi_scale)),
|
||
int(self.args.imgsz * (1.0 + self.args.multi_scale) + self.stride),
|
||
)
|
||
// self.stride
|
||
* self.stride
|
||
) # size
|
||
sf = sz / max(imgs.shape[2:]) # scale factor
|
||
if sf != 1:
|
||
ns = [
|
||
math.ceil(x * sf / self.stride) * self.stride for x in imgs.shape[2:]
|
||
] # new shape (stretched to gs-multiple)
|
||
imgs = nn.functional.interpolate(imgs, size=ns, mode="bilinear", align_corners=False)
|
||
batch["img"] = imgs
|
||
return batch
|
||
|
||
def set_model_attributes(self):
|
||
"""Set model attributes based on dataset information."""
|
||
# Nl = de_parallel(self.model).model[-1].nl # number of detection layers (to scale hyps)
|
||
# self.args.box *= 3 / nl # scale to layers
|
||
# self.args.cls *= self.data["nc"] / 80 * 3 / nl # scale to classes and layers
|
||
# self.args.cls *= (self.args.imgsz / 640) ** 2 * 3 / nl # scale to image size and layers
|
||
self.model.nc = self.data["nc"] # attach number of classes to model
|
||
self.model.names = self.data["names"] # attach class names to model
|
||
self.model.args = self.args # attach hyperparameters to model
|
||
if getattr(self.model, "end2end"):
|
||
self.model.set_head_attr(max_det=self.args.max_det)
|
||
# TODO: self.model.class_weights = labels_to_class_weights(dataset.labels, nc).to(device) * nc
|
||
|
||
def get_model(self, cfg: str | None = None, weights: str | None = None, verbose: bool = True):
|
||
"""Return a YOLO detection model.
|
||
|
||
Args:
|
||
cfg (str, optional): Path to model configuration file.
|
||
weights (str, optional): Path to model weights.
|
||
verbose (bool): Whether to display model information.
|
||
|
||
Returns:
|
||
(DetectionModel): YOLO detection model.
|
||
"""
|
||
model = DetectionModel(cfg, nc=self.data["nc"], ch=self.data["channels"], verbose=verbose and RANK == -1)
|
||
if weights:
|
||
model.load(weights)
|
||
return model
|
||
|
||
def get_validator(self):
|
||
"""Return a DetectionValidator for YOLO model validation."""
|
||
self.loss_names = "box_loss", "cls_loss", "dfl_loss", "diff_loss"
|
||
return yolo.detect.DetectionValidator(
|
||
self.test_loader, save_dir=self.save_dir, args=copy(self.args), _callbacks=self.callbacks
|
||
)
|
||
|
||
def label_loss_items(self, loss_items: list[float] | None = None, prefix: str = "train"):
|
||
"""Return a loss dict with labeled training loss items tensor.
|
||
|
||
Args:
|
||
loss_items (list[float], optional): List of loss values.
|
||
prefix (str): Prefix for keys in the returned dictionary.
|
||
|
||
Returns:
|
||
(dict | list): Dictionary of labeled loss items if loss_items is provided, otherwise list of keys.
|
||
"""
|
||
keys = [f"{prefix}/{x}" for x in self.loss_names]
|
||
if loss_items is not None:
|
||
loss_items = [round(float(x), 5) for x in loss_items] # convert tensors to 5 decimal place floats
|
||
return dict(zip(keys, loss_items))
|
||
else:
|
||
return keys
|
||
|
||
def progress_string(self):
|
||
"""Return a formatted string of training progress with epoch, GPU memory, loss, instances and size."""
|
||
leadw, colw = 7, 9
|
||
loss_names = list(self.loss_names)
|
||
extra_names = list(self.progress_extra_names())
|
||
return ("\n" + f"%{leadw}s" + f"%{colw}s" * (3 + len(loss_names) + len(extra_names))) % (
|
||
"Epoch",
|
||
"GPU_mem",
|
||
*loss_names,
|
||
"Inst",
|
||
"Size",
|
||
*extra_names,
|
||
)
|
||
|
||
def progress_extra_names(self):
|
||
"""Return extra per-batch progress columns appended after the loss items."""
|
||
return ()
|
||
|
||
def progress_extra_values(self):
|
||
"""Return extra per-batch progress values appended after the loss items."""
|
||
return ()
|
||
|
||
def plot_training_samples(self, batch: dict[str, Any], ni: int) -> None:
|
||
"""Plot training samples with their annotations.
|
||
|
||
Args:
|
||
batch (dict[str, Any]): Dictionary containing batch data.
|
||
ni (int): Batch index used for naming the output file.
|
||
"""
|
||
plot_images(
|
||
labels=batch,
|
||
paths=batch["im_file"],
|
||
fname=self.save_dir / f"train_batch{ni}.jpg",
|
||
on_plot=self.on_plot,
|
||
)
|
||
|
||
def plot_training_labels(self):
|
||
"""Create a labeled training plot of the YOLO model."""
|
||
boxes = np.concatenate([lb["bboxes"] for lb in self.train_loader.dataset.labels], 0)
|
||
cls = np.concatenate([lb["cls"] for lb in self.train_loader.dataset.labels], 0)
|
||
plot_labels(boxes, cls.squeeze(), names=self.data["names"], save_dir=self.save_dir, on_plot=self.on_plot)
|
||
|
||
def auto_batch(self):
|
||
"""Get optimal batch size by calculating memory occupation of model.
|
||
|
||
Returns:
|
||
(int): Optimal batch size.
|
||
"""
|
||
with override_configs(self.args, overrides={"cache": False}) as self.args:
|
||
train_dataset = self.build_dataset(self.data["train"], mode="train", batch=16)
|
||
max_num_obj = max(len(label["cls"]) for label in train_dataset.labels) * 4 # 4 for mosaic augmentation
|
||
del train_dataset # free memory
|
||
return super().auto_batch(max_num_obj)
|
||
|
||
|
||
class GroundDetectionValidator(DetectionValidator):
|
||
"""A validator for ground 2D detection.
|
||
|
||
This validator extends DetectionValidator for ground 2D detection tasks.
|
||
YUV444 to BGR conversion is handled in the dataloader, so no special preprocessing needed here.
|
||
"""
|
||
|
||
def _use_roi_metrics_only(self, batch: dict[str, Any]) -> bool:
|
||
"""Return whether validation metrics should ignore virtual-camera samples."""
|
||
return bool(getattr(self.args, "roi_metrics_only", False)) and isinstance(batch.get("camera_mode"), (list, tuple))
|
||
|
||
def _is_roi_metric_sample(self, batch: dict[str, Any], sample_idx: int) -> bool:
|
||
"""Return whether a sample should contribute to validation metrics."""
|
||
camera_mode = batch.get("camera_mode")
|
||
if not self._use_roi_metrics_only(batch):
|
||
return True
|
||
return sample_idx < len(camera_mode) and camera_mode[sample_idx] == "roi"
|
||
|
||
def preprocess(self, batch: dict[str, Any]) -> dict[str, Any]:
|
||
"""Preprocess batch (YUV444→BGR conversion already done in dataloader).
|
||
|
||
Args:
|
||
batch (dict[str, Any]): Batch containing images and annotations.
|
||
|
||
Returns:
|
||
(dict[str, Any]): Preprocessed batch with normalized BGR images.
|
||
"""
|
||
# Move to device
|
||
for k, v in batch.items():
|
||
if isinstance(v, torch.Tensor):
|
||
batch[k] = v.to(self.device, non_blocking=self.device.type == "cuda")
|
||
|
||
# Normalize to [0, 1] (images are already in BGR format from dataloader)
|
||
batch["img"] = (batch["img"].half() if self.args.half else batch["img"].float()) / 256
|
||
return batch
|
||
|
||
def update_metrics(self, preds: list[dict[str, torch.Tensor]], batch: dict[str, Any]) -> None:
|
||
"""Update metrics with new predictions and ground truth, optionally ROI-only."""
|
||
for si, pred in enumerate(preds):
|
||
if not self._is_roi_metric_sample(batch, si):
|
||
continue
|
||
self.seen += 1
|
||
pbatch = self._prepare_batch(si, batch)
|
||
predn = self._prepare_pred(pred)
|
||
|
||
cls = pbatch["cls"].cpu().numpy()
|
||
no_pred = predn["cls"].shape[0] == 0
|
||
self.metrics.update_stats(
|
||
{
|
||
**self._process_batch(predn, pbatch),
|
||
"target_cls": cls,
|
||
"target_img": np.unique(cls),
|
||
"conf": np.zeros(0) if no_pred else predn["conf"].cpu().numpy(),
|
||
"pred_cls": np.zeros(0) if no_pred else predn["cls"].cpu().numpy(),
|
||
}
|
||
)
|
||
if self.args.plots:
|
||
self.confusion_matrix.process_batch(predn, pbatch, conf=self.args.conf)
|
||
if self.args.visualize:
|
||
self.confusion_matrix.plot_matches(batch["img"][si], pbatch["im_file"], self.save_dir)
|
||
|
||
if no_pred:
|
||
continue
|
||
|
||
if self.args.save_json or self.args.save_txt:
|
||
predn_scaled = self.scale_preds(predn, pbatch)
|
||
if self.args.save_json:
|
||
self.pred_to_json(predn_scaled, pbatch)
|
||
if self.args.save_txt:
|
||
self.save_one_txt(
|
||
predn_scaled,
|
||
self.args.save_conf,
|
||
pbatch["ori_shape"],
|
||
self.save_dir / "labels" / f"{Path(pbatch['im_file']).stem}.txt",
|
||
)
|
||
|
||
|
||
class GroundDetectionTrainer(DetectionTrainer):
|
||
"""A class extending DetectionTrainer for training ground 2D detection with difficulty scores.
|
||
|
||
This trainer specializes in ground 2D detection tasks with custom annotation format including:
|
||
- String class names mapped to numeric IDs via class_map
|
||
- Difficulty scores for each bounding box
|
||
- Difficulty-based loss weighting
|
||
- YUV444 to BGR conversion (handled in dataloader)
|
||
|
||
Attributes:
|
||
model (DetectionModel): The YOLO detection model being trained.
|
||
data (dict): Dictionary containing dataset information including class_map and number of classes.
|
||
loss_names (tuple): Names of the loss components used in training (box_loss, cls_loss, dfl_loss).
|
||
|
||
Examples:
|
||
>>> from ultralytics.models.yolo.detect import GroundDetectionTrainer
|
||
>>> args = dict(model="yolo26n.pt", data="ground_dataset.yaml", epochs=100)
|
||
>>> trainer = GroundDetectionTrainer(overrides=args)
|
||
>>> trainer.train()
|
||
"""
|
||
|
||
def __init__(self, cfg=DEFAULT_CFG, overrides: dict[str, Any] | None = None, _callbacks=None):
|
||
"""Initialize a GroundDetectionTrainer object.
|
||
|
||
Args:
|
||
cfg (dict, optional): Default configuration dictionary containing training parameters.
|
||
overrides (dict, optional): Dictionary of parameter overrides for the default configuration.
|
||
_callbacks (list, optional): List of callback functions to be executed during training.
|
||
"""
|
||
super().__init__(cfg, overrides, _callbacks)
|
||
# YUV444 conversion is now handled in the dataloader
|
||
|
||
def preprocess_batch(self, batch: dict) -> dict:
|
||
"""Preprocess a batch of images.
|
||
|
||
Args:
|
||
batch (dict): Dictionary containing batch data with 'img' tensor.
|
||
|
||
Returns:
|
||
(dict): Preprocessed batch with normalized images in BGR format.
|
||
"""
|
||
# Move batch to device first
|
||
for k, v in batch.items():
|
||
if isinstance(v, torch.Tensor):
|
||
batch[k] = v.to(self.device, non_blocking=self.device.type == "cuda")
|
||
|
||
# Normalize to [0, 1] (YUV444→BGR conversion already done in dataloader)
|
||
batch["img"] = batch["img"].float() / 256
|
||
|
||
# Multi-scale training
|
||
if self.args.multi_scale > 0.0:
|
||
imgs = batch["img"]
|
||
sz = (
|
||
random.randrange(
|
||
int(self.args.imgsz * (1.0 - self.args.multi_scale)),
|
||
int(self.args.imgsz * (1.0 + self.args.multi_scale) + self.stride),
|
||
)
|
||
// self.stride
|
||
* self.stride
|
||
) # size
|
||
sf = sz / max(imgs.shape[2:]) # scale factor
|
||
if sf != 1:
|
||
ns = [
|
||
math.ceil(x * sf / self.stride) * self.stride for x in imgs.shape[2:]
|
||
] # new shape (stretched to gs-multiple)
|
||
imgs = nn.functional.interpolate(imgs, size=ns, mode="bilinear", align_corners=False)
|
||
batch["img"] = imgs
|
||
return batch
|
||
|
||
def build_dataset(self, img_path: str, mode: str = "train", batch: int | None = None):
|
||
"""Build YOLO Dataset for training or validation.
|
||
|
||
Args:
|
||
img_path (str): Path to the folder containing images.
|
||
mode (str): 'train' mode or 'val' mode.
|
||
batch (int, optional): Size of batches, this is for 'rect' mode.
|
||
|
||
Returns:
|
||
(Dataset): YOLO dataset object configured for the specified mode.
|
||
"""
|
||
return super().build_dataset(img_path, mode, batch)
|
||
|
||
def set_model_attributes(self):
|
||
"""Set model attributes based on ground dataset information."""
|
||
# The class_map to names conversion is now handled in check_det_dataset
|
||
# This method just calls the parent implementation
|
||
super().set_model_attributes()
|
||
|
||
def get_model(self, cfg: str | None = None, weights: str | None = None, verbose: bool = True):
|
||
"""Return a YOLO detection model with ground loss function.
|
||
|
||
Args:
|
||
cfg (str, optional): Path to model configuration file.
|
||
weights (str, optional): Path to model weights.
|
||
verbose (bool): Whether to display model information.
|
||
|
||
Returns:
|
||
(GroundDetectionModel): YOLO detection model with ground loss.
|
||
"""
|
||
model = GroundDetectionModel(cfg, nc=self.data["nc"], ch=self.data["channels"], verbose=verbose and RANK == -1)
|
||
if weights:
|
||
model.load(weights)
|
||
return model
|
||
|
||
def get_validator(self):
|
||
"""Return a GroundDetectionValidator for YOLO model validation.
|
||
|
||
Returns:
|
||
(GroundDetectionValidator): Validator for ground detection.
|
||
"""
|
||
self.loss_names = "box_loss", "cls_loss", "dfl_loss"
|
||
return GroundDetectionValidator(
|
||
self.test_loader, save_dir=self.save_dir, args=copy(self.args), _callbacks=self.callbacks
|
||
)
|
||
|
||
|
||
class Ground3DDetectionModel(DetectionModel):
|
||
"""YOLO detection model with joint 2D+3D ground loss."""
|
||
|
||
def init_criterion(self):
|
||
"""Initialize the loss criterion for ground 3D detection."""
|
||
from ultralytics.utils.loss import E2EGround3DLoss, v8Detection3DLoss
|
||
|
||
return E2EGround3DLoss(self) if getattr(self, "end2end", False) else v8Detection3DLoss(self)
|
||
|
||
|
||
class Ground3DDetectionTrainer(GroundDetectionTrainer):
|
||
"""Trainer for joint 2D+3D ground detection.
|
||
|
||
Extends GroundDetectionTrainer with:
|
||
- Ground3DDetectionModel with 3D loss
|
||
- YOLOGround3DDataset for on-the-fly 3D label loading
|
||
- 3D loss weight ramping during training
|
||
- Support for rectangular imgsz (e.g., [704, 352])
|
||
"""
|
||
|
||
def setup_model(self):
|
||
"""Load 3D model config and optionally pretrained weights."""
|
||
from pathlib import Path
|
||
|
||
from ultralytics.engine.trainer import load_checkpoint
|
||
|
||
cfg = self.model # yaml path, e.g. "yolo26-3d.yaml"
|
||
weights = None
|
||
explicit_scale = getattr(self, "explicit_model_scale", None) # 尝试读取显式模型 scale,比如 n/s/l/m/x。如果对象上没有这个属性,就返回 None
|
||
|
||
# Load pretrained weights if specified (e.g. "yolo26s-pretrain.pt")
|
||
if isinstance(self.args.pretrained, (str, Path)):
|
||
weights, _ = load_checkpoint(self.args.pretrained)
|
||
if explicit_scale is None:
|
||
pretrained_stem = Path(self.args.pretrained).stem
|
||
if pretrained_stem.startswith("yolo26") and len(pretrained_stem) > len("yolo26"):
|
||
scale = pretrained_stem[len("yolo26")]
|
||
if scale in "nslmx":
|
||
explicit_scale = scale
|
||
self.explicit_model_scale = scale
|
||
|
||
cfg = self._resolve_model_cfg(cfg, explicit_scale)
|
||
|
||
self.model = self.get_model(cfg=cfg, weights=weights, verbose=RANK == -1)
|
||
return None
|
||
|
||
@staticmethod
|
||
def _resolve_model_cfg(cfg, explicit_scale=None):
|
||
"""Resolve the model YAML/config dict and optionally force an explicit scale."""
|
||
from ultralytics.nn.tasks import yaml_model_load
|
||
|
||
if isinstance(cfg, (str, Path)):
|
||
cfg = yaml_model_load(cfg)
|
||
else:
|
||
cfg = dict(cfg)
|
||
if explicit_scale:
|
||
cfg["scale"] = explicit_scale
|
||
return cfg
|
||
|
||
@staticmethod
|
||
def _backbone_param_keys(model):
|
||
"""Return backbone parameter keys based on the parsed YAML backbone definition.定义静态方法,用于找出模型 backbone 部分的参数名"""
|
||
backbone = (getattr(model, "yaml", None) or {}).get("backbone")
|
||
if not isinstance(backbone, list) or not backbone:
|
||
raise RuntimeError("Unable to verify pretrained backbone load because the model YAML has no backbone.")
|
||
prefixes = tuple(f"model.{i}." for i in range(len(backbone)))
|
||
return sorted(k for k, _ in model.named_parameters() if k.startswith(prefixes))
|
||
|
||
def _verify_pretrained_backbone_load(self, model, weights):
|
||
"""Raise if any backbone parameter tensor fails to transfer from pretrained weights. 根据模型 yaml 中的 backbone 定义返回 backbone 参数 key"""
|
||
source_model = weights["model"] if isinstance(weights, dict) else weights
|
||
source_params = dict(source_model.named_parameters())
|
||
target_params = dict(model.named_parameters())
|
||
target_backbone_keys = self._backbone_param_keys(model)
|
||
if not target_backbone_keys:
|
||
raise RuntimeError("Unable to find any backbone parameters to verify in the target Ground3D model.")
|
||
|
||
source_state_dict = source_model.float().state_dict()
|
||
target_state_dict = model.state_dict()
|
||
transferred_state_dict = intersect_dicts(source_state_dict, target_state_dict)
|
||
|
||
missing = [k for k in target_backbone_keys if k not in source_params]
|
||
mismatched = [
|
||
k
|
||
for k in target_backbone_keys
|
||
if k in source_params and (source_params[k].shape != target_params[k].shape or k not in transferred_state_dict)
|
||
]
|
||
if missing or mismatched:
|
||
details = []
|
||
if missing:
|
||
details.append(f"missing={len(missing)} (e.g. {', '.join(missing[:5])})")
|
||
if mismatched:
|
||
details.append(f"shape_mismatch={len(mismatched)} (e.g. {', '.join(mismatched[:5])})")
|
||
scale = (getattr(model, "yaml", None) or {}).get("scale")
|
||
scale_msg = f" scale='{scale}'" if scale else ""
|
||
raise RuntimeError(
|
||
"Pretrained weights failed strict backbone verification for the Ground3D model"
|
||
f"{scale_msg}. "
|
||
"Use a matching '--model yolo26n/yolo26s/...' shorthand or a compatible checkpoint. "
|
||
f"Details: {' | '.join(details)}"
|
||
)
|
||
|
||
LOGGER.info(f"Verified strict pretrained backbone load for {len(target_backbone_keys)} parameter tensors")
|
||
|
||
def get_model(self, cfg=None, weights=None, verbose=True):
|
||
"""Return a YOLO detection model with 3D loss function."""
|
||
cfg = self._resolve_model_cfg(cfg, getattr(self, "explicit_model_scale", None))
|
||
model = Ground3DDetectionModel(cfg, nc=self.data["nc"], ch=self.data["channels"], verbose=verbose and RANK == -1)
|
||
if weights:
|
||
if getattr(self, "strict_backbone_pretrained", True):
|
||
self._verify_pretrained_backbone_load(model, weights)
|
||
model.load(weights)
|
||
# Attach 3D config to model for loss function
|
||
model.norm_scales_3d = self.data.get("norm_scales_3d", {})
|
||
model.face_3d_classes = set(self.data.get("face_3d_classes", []))
|
||
model.complete_3d_classes = set(self.data.get("complete_3d_classes", []))
|
||
fake_3d_classes = self.data.get("fake_3d_classes")
|
||
if fake_3d_classes is None:
|
||
class_map = self.data.get("class_map", {})
|
||
fake_3d_classes = [v for k, v in class_map.items() if isinstance(k, str) and k.endswith("_fake")]
|
||
model.fake_3d_classes = set(fake_3d_classes or [])
|
||
# Propagate norm_scales_3d to Detect3D head for denormalization
|
||
from ultralytics.nn.modules.head import Detect3D
|
||
|
||
for m in model.modules():
|
||
if isinstance(m, Detect3D):
|
||
m.norm_scales_3d = model.norm_scales_3d
|
||
return model
|
||
|
||
def plot_training_labels(self):
|
||
"""Skip label plotting because Ground3D labels are parsed on-the-fly."""
|
||
LOGGER.info("Skipping label plot (on-the-fly label loading)")
|
||
|
||
def auto_batch(self):
|
||
"""Ground3D GT-list datasets require an explicit batch size."""
|
||
raise ValueError("AutoBatch is not supported for Ground3D GT-list datasets. Please set --batch explicitly.")
|
||
|
||
def build_dataset(self, img_path, mode="train", batch=None):
|
||
"""Build YOLOGround3DDataset for 3D detection training. 定义数据集构建函数。mode 可以是 "train" 或 "val" """
|
||
from ultralytics.data.dataset import YOLOGround3DDataset
|
||
|
||
gs = max(int(unwrap_model(self.model).stride.max()), 32)
|
||
cfg = self.args
|
||
if RANK in {-1, 0}:
|
||
LOGGER.info(f"{mode}: Building Ground3D dataset from {img_path}")
|
||
dataset = YOLOGround3DDataset(
|
||
img_path=img_path,
|
||
imgsz=cfg.imgsz,
|
||
batch_size=batch,
|
||
augment=mode == "train",
|
||
hyp=cfg,
|
||
rect=cfg.rect or (mode == "val"),
|
||
cache=cfg.cache or None,
|
||
single_cls=cfg.single_cls or False,
|
||
stride=gs,
|
||
pad=0.0 if mode == "train" else 0.5,
|
||
prefix=f"{mode}: ",
|
||
task=cfg.task,
|
||
classes=cfg.classes,
|
||
data=self.data,
|
||
fraction=cfg.fraction if mode == "train" else 1.0,
|
||
)
|
||
if RANK in {-1, 0}:
|
||
LOGGER.info(f"{mode}: Ground3D dataset ready with {len(dataset):,} samples")
|
||
return dataset
|
||
|
||
def preprocess_batch(self, batch):
|
||
"""Preprocess batch and ramp 3D loss weight. 除了预处理 batch,还会动态调整 3D loss 权重"""
|
||
batch = super().preprocess_batch(batch)
|
||
model = unwrap_model(self.model) # 拿到真实模型对象,避免 DDP 包装影响访问属性
|
||
if hasattr(model, "criterion"):
|
||
criterion = model.criterion
|
||
w = self._compute_3d_weight() # 计算当前 epoch 应该使用的 3D loss 权重
|
||
if hasattr(criterion, "one2many"):
|
||
criterion.one2many.loss_3d_weight = w
|
||
criterion.one2one.loss_3d_weight = w
|
||
elif hasattr(criterion, "loss_3d_weight"):
|
||
criterion.loss_3d_weight = w
|
||
return batch
|
||
|
||
def progress_extra_names(self):
|
||
"""Expose lr and current 3D loss weight in the live training progress bar. 进度条额外显示两个值:学习率 lr 和当前 3D loss 权重 w3d"""
|
||
return ("lr", "w3d")
|
||
|
||
def progress_extra_values(self):
|
||
"""Return current lr and active 3D loss weight for the live training progress bar. 定义训练进度条额外显示字段值"""
|
||
lr = self.optimizer.param_groups[0]["lr"] if getattr(self, "optimizer", None) and self.optimizer.param_groups else 0.0
|
||
criterion = getattr(unwrap_model(self.model), "criterion", None)
|
||
if hasattr(criterion, "one2many"):
|
||
w3d = float(getattr(criterion.one2many, "loss_3d_weight", 0.0))
|
||
else:
|
||
w3d = float(getattr(criterion, "loss_3d_weight", 0.0)) if criterion is not None else 0.0
|
||
return (f"{lr:.1e}", f"{w3d:.3g}")
|
||
|
||
def _compute_3d_weight(self):
|
||
"""Progressively ramp 3D loss weight after the 2D-only warmup stage."""
|
||
warmup = getattr(self.args, "loss_3d_warmup_epochs", 10.0) # 读取 3D loss warmup epoch,默认 10。warmup 阶段 3D loss 权重为 0,只训练 2D。
|
||
ramp_epochs = getattr(self.args, "loss_3d_ramp_epochs", 10.0) # 读取 ramp 阶段长度,默认 10 个 epoch。 也就是在这个范围逐步增长
|
||
max_weight = float(getattr(self.args, "loss_3d_weight_max", 0.1)) # 读取 3D loss 最大权重,默认 0.1。
|
||
epoch = self.epoch
|
||
warmup = float(warmup)
|
||
if epoch < warmup:
|
||
return 0.0
|
||
progress = min((epoch - warmup) / ramp_epochs, 1.0)
|
||
return max_weight * progress
|
||
|
||
def get_validator(self):
|
||
"""Return validator with 3D loss names."""
|
||
self.loss_names = (
|
||
"box", "cls", "dfl", "diff",
|
||
"z3d", "uv", "size", "ycls", "ydeg",
|
||
"ccls", "fz", "fuv", "fsize", "fcls", "euv", "ez",
|
||
)
|
||
return Ground3DDetectionValidator(
|
||
self.test_loader, save_dir=self.save_dir, args=copy(self.args), _callbacks=self.callbacks
|
||
)
|
||
"""
|
||
box 2D bbox loss
|
||
cls 分类 loss
|
||
dfl Distribution Focal Loss
|
||
diff difficulty 难度分支 loss
|
||
z3d 3D 深度 loss
|
||
uv 3D 关键点/投影点 2D 坐标 loss
|
||
size 3D 尺寸 loss
|
||
ycls yaw 分类 loss
|
||
ydeg yaw 角度回归 loss
|
||
ccls complete/类别相关 3D 分类 loss
|
||
fz face 深度 loss
|
||
fuv face 2D 点 loss
|
||
fsize face 尺寸 loss
|
||
fcls face 类别/可见性 loss
|
||
euv edge 2D 点 loss
|
||
ez edge 深度 loss
|
||
"""
|
||
|
||
class Ground3DDetectionValidator(GroundDetectionValidator):
|
||
"""Validator for ground 3D detection with 3D metrics computation.
|
||
|
||
Extends GroundDetectionValidator to:
|
||
- Capture 3D predictions from model output during postprocess
|
||
- Compute 3D metrics (depth, orientation, center, size errors) for matched GT-pred pairs
|
||
- Include 3D metrics in validation results for TensorBoard logging
|
||
"""
|
||
|
||
def __init__(self, dataloader=None, save_dir=None, args=None, _callbacks=None):
|
||
"""Initialize with 3D metrics storage."""
|
||
super().__init__(dataloader, save_dir, args, _callbacks)
|
||
self.stats_3d = {"whole": [], "face": []}
|
||
self.metrics_3d_results = self._empty_3d_metrics()
|
||
self._preds_3d_selected = None
|
||
self._preds_edge_selected = None
|
||
self._preds_diff_selected = None
|
||
self._anchors_selected = None
|
||
self._strides_selected = None
|
||
self._current_batch_calib = None
|
||
self.face_3d_classes = set()
|
||
self.complete_3d_classes = set()
|
||
self.fake_3d_classes = set()
|
||
|
||
@staticmethod
|
||
def _empty_3d_metrics():
|
||
"""Return default grouped 3D metrics used for logging when no matches are available."""
|
||
from ultralytics.utils.metrics_3d import empty_3d_metrics
|
||
|
||
return {
|
||
"whole": empty_3d_metrics(include_orient=True, include_size=True, include_uv=True),
|
||
"face": empty_3d_metrics(include_orient=False, include_size=True, include_uv=True, include_visible_orient=True),
|
||
}
|
||
|
||
def init_metrics(self, model):
|
||
"""Initialize metrics and extract 3D class config from model."""
|
||
super().init_metrics(model)
|
||
self.stats_3d = {"whole": [], "face": []}
|
||
self.metrics_3d_results = self._empty_3d_metrics()
|
||
self._current_batch_calib = None
|
||
# Extract 3D config from model
|
||
if hasattr(model, "module"):
|
||
model = model.module
|
||
self.face_3d_classes = getattr(model, "face_3d_classes", set())
|
||
self.complete_3d_classes = getattr(model, "complete_3d_classes", set())
|
||
self.fake_3d_classes = getattr(model, "fake_3d_classes", set())
|
||
|
||
def get_desc(self) -> str:
|
||
"""Return a compact loss-aligned 3D metric title row for tqdm."""
|
||
trainer = getattr(self, "trainer", None)
|
||
loss_ref_names = getattr(trainer, "loss_names", None) or (
|
||
"box",
|
||
"cls",
|
||
"dfl",
|
||
"z3d",
|
||
"uv",
|
||
"size",
|
||
"ycls",
|
||
"ydeg",
|
||
"ccls",
|
||
"fz",
|
||
"fuv",
|
||
"fsize",
|
||
"fcls",
|
||
"euv",
|
||
"ez",
|
||
)
|
||
leadw, colw = 7, 9
|
||
desc_values = [
|
||
"",
|
||
"Metric3D",
|
||
"Wmatch",
|
||
"Fmatch",
|
||
"-",
|
||
"WdAbs",
|
||
"Wuv",
|
||
"Wsize",
|
||
"-",
|
||
"Wyaw",
|
||
"-",
|
||
"FdAbs",
|
||
"Fuv",
|
||
"Fsize",
|
||
"-",
|
||
"Vyaw",
|
||
]
|
||
desc_values.extend(["-"] * (1 + len(loss_ref_names) - len(desc_values)))
|
||
return (f"%{leadw}s" + f"%{colw}s" * len(loss_ref_names)) % tuple(desc_values[: 1 + len(loss_ref_names)])
|
||
|
||
def get_header(self) -> str:
|
||
"""Return no extra one-time header; tqdm description already contains the compact metric titles."""
|
||
return ""
|
||
|
||
def _face_desc(self) -> str:
|
||
"""Return header for the face-only 3D metric line."""
|
||
return ("%22s" + "%11s" * (2 + len(self.metrics.keys) + 6)) % (
|
||
"Class",
|
||
"",
|
||
"",
|
||
*("" for _ in self.metrics.keys),
|
||
"FdAbs",
|
||
"FdRel",
|
||
"FdRMSE",
|
||
"Fctr",
|
||
"Fuv",
|
||
"Fmatch",
|
||
)
|
||
|
||
def preprocess(self, batch):
|
||
"""Preprocess batch and keep per-image calibration for 3D prediction restoration."""
|
||
self._current_batch_calib = batch.get("calib")
|
||
return super().preprocess(batch)
|
||
|
||
def postprocess(self, preds):
|
||
"""Capture 3D predictions, restore depth once, and filter by confidence."""
|
||
# Extract 3D predictions from raw model output: (y, preds_dict)
|
||
self._preds_3d_filtered = None
|
||
self._anchors_filtered = None
|
||
self._strides_filtered = None
|
||
self._preds_edge_filtered = None
|
||
|
||
if isinstance(preds, (list, tuple)) and len(preds) >= 2:
|
||
y = preds[0] # (B, k, 6) from head's postprocess
|
||
preds_dict = preds[1]
|
||
one2one = preds_dict.get("one2one", {})
|
||
preds_3d_sel = one2one.get("preds_3d_selected") # (B, k, 41) or None
|
||
preds_3d_fake_sel = one2one.get("preds_3d_fake_selected") # (B, k, 41) or None
|
||
preds_edge_sel = one2one.get("preds_edge_selected") # (B, k, 60) or None
|
||
preds_diff_sel = one2one.get("preds_diff_selected") # (B, k, 1) or None
|
||
anchors_sel = one2one.get("anchors_selected") # (B, 2, k) or None
|
||
strides_sel = one2one.get("strides_selected") # (B, k) or None
|
||
|
||
if preds_3d_sel is not None and anchors_sel is not None and strides_sel is not None:
|
||
preds_3d_sel = preds_3d_sel.clone()
|
||
if preds_3d_fake_sel is not None and self.fake_3d_classes:
|
||
fake_cls = torch.as_tensor(sorted(self.fake_3d_classes), device=y.device, dtype=y.dtype)
|
||
fake_mask = (y[..., 5:6] == fake_cls.view(1, 1, -1)).any(dim=-1)
|
||
preds_3d_sel[fake_mask] = preds_3d_fake_sel[fake_mask]
|
||
if preds_edge_sel is not None:
|
||
preds_edge_sel = preds_edge_sel.clone()
|
||
batch_calib = self._current_batch_calib
|
||
if batch_calib is not None:
|
||
z_channels = (0, 6, 12, 18, 24)
|
||
for si in range(min(preds_3d_sel.shape[0], len(batch_calib))):
|
||
calib = batch_calib[si]
|
||
if calib is None:
|
||
continue
|
||
depth_scale = calib.get("depth_scale", 1.0)
|
||
if depth_scale == 1.0:
|
||
continue
|
||
for ch in z_channels:
|
||
preds_3d_sel[si, :, ch] *= depth_scale
|
||
if preds_edge_sel is not None:
|
||
preds_edge_sel[si, :, 2::3] *= depth_scale
|
||
|
||
# Mirror end2end NMS: filter by confidence and limit to max_det
|
||
# NMS does: pred[pred[:, 4] > conf_thres][:max_det]
|
||
self._preds_3d_filtered = []
|
||
self._preds_edge_filtered = []
|
||
self._anchors_filtered = []
|
||
self._strides_filtered = []
|
||
for si in range(y.shape[0]):
|
||
conf_mask = y[si, :, 4] > self.args.conf
|
||
p3d_f = preds_3d_sel[si][conf_mask][: self.args.max_det]
|
||
pedge_f = preds_edge_sel[si][conf_mask][: self.args.max_det] if preds_edge_sel is not None else None
|
||
anc_f = anchors_sel[si][:, conf_mask][:, : self.args.max_det]
|
||
str_f = strides_sel[si][conf_mask][: self.args.max_det]
|
||
self._preds_3d_filtered.append(p3d_f)
|
||
self._preds_edge_filtered.append(pedge_f)
|
||
self._anchors_filtered.append(anc_f)
|
||
self._strides_filtered.append(str_f)
|
||
|
||
# Also store unfiltered for TensorBoard visualization (uses its own conf filter)
|
||
self._preds_3d_selected = preds_3d_sel
|
||
self._preds_edge_selected = preds_edge_sel
|
||
self._preds_diff_selected = preds_diff_sel
|
||
self._anchors_selected = anchors_sel
|
||
self._strides_selected = strides_sel
|
||
|
||
return super().postprocess(preds)
|
||
|
||
def update_metrics(self, preds, batch):
|
||
"""Update 2D metrics and compute 3D metrics for matched GT-pred pairs."""
|
||
super().update_metrics(preds, batch)
|
||
|
||
labels_3d = batch.get("labels_3d")
|
||
if labels_3d is None or self._preds_3d_filtered is None:
|
||
return
|
||
|
||
self._compute_3d_metrics_for_batch(preds, batch)
|
||
|
||
def gather_stats(self) -> None:
|
||
"""Gather 2D and 3D stats from all GPUs."""
|
||
super().gather_stats()
|
||
if getattr(self, "_single_rank_eval", False) or not (dist.is_available() and dist.is_initialized()):
|
||
return
|
||
if RANK == 0:
|
||
gathered_stats_3d = [None] * dist.get_world_size()
|
||
dist.gather_object(self.stats_3d, gathered_stats_3d, dst=0)
|
||
merged_stats_3d = {"whole": [], "face": []}
|
||
for stats_dict in gathered_stats_3d:
|
||
for key in merged_stats_3d:
|
||
merged_stats_3d[key].extend(stats_dict.get(key, []))
|
||
self.stats_3d = merged_stats_3d
|
||
else:
|
||
dist.gather_object(self.stats_3d, None, dst=0)
|
||
self.stats_3d = {"whole": [], "face": []}
|
||
|
||
def _append_metric_attr(self, attr_store, attr):
|
||
"""Append a decoded 3D attribute dict into the metric store."""
|
||
if attr is None:
|
||
return
|
||
for key in attr_store:
|
||
attr_store[key].append(attr[key])
|
||
|
||
@staticmethod
|
||
def _metric_store():
|
||
"""Return an empty metric attribute store."""
|
||
return {"center": [], "depth": [], "yaw": [], "edge_yaw": [], "dims": [], "uv": []}
|
||
|
||
def _aggregate_metric_store(self, group, pred_store, gt_store, include_visible_orient=None):
|
||
"""Aggregate one metric group and append it to stats."""
|
||
from ultralytics.utils.metrics_3d import compute_3d_metrics_for_matched
|
||
|
||
if not pred_store["depth"]:
|
||
return
|
||
if include_visible_orient is None:
|
||
include_visible_orient = group == "face"
|
||
pred_np = {k: np.array(v) for k, v in pred_store.items()}
|
||
gt_np = {k: np.array(v) for k, v in gt_store.items()}
|
||
metrics = compute_3d_metrics_for_matched(
|
||
pred_np,
|
||
gt_np,
|
||
include_orient=group == "whole",
|
||
include_size=True,
|
||
include_uv=True,
|
||
include_visible_orient=include_visible_orient,
|
||
)
|
||
self.stats_3d[group].append(metrics)
|
||
|
||
@staticmethod
|
||
def _whole_metric_store():
|
||
"""Return empty stores for whole-position and whole-shape metrics."""
|
||
return {
|
||
"pos_pred": Ground3DDetectionValidator._metric_store(),
|
||
"pos_gt": Ground3DDetectionValidator._metric_store(),
|
||
"shape_pred": Ground3DDetectionValidator._metric_store(),
|
||
"shape_gt": Ground3DDetectionValidator._metric_store(),
|
||
}
|
||
|
||
def _aggregate_whole_metric_store(self, stores):
|
||
"""Aggregate whole-box metrics with separate validity for position and shape terms."""
|
||
from ultralytics.utils.metrics_3d import (
|
||
compute_3d_metrics_for_matched,
|
||
compute_orientation_error,
|
||
compute_size_error,
|
||
empty_3d_metrics,
|
||
)
|
||
|
||
pos_depth = stores["pos_pred"]["depth"]
|
||
shape_yaw = stores["shape_pred"]["yaw"]
|
||
if not pos_depth and not shape_yaw:
|
||
return
|
||
|
||
metrics = empty_3d_metrics(include_orient=True, include_size=True, include_uv=True)
|
||
metrics["matched"] = len(stores["shape_pred"]["depth"])
|
||
if pos_depth:
|
||
pos_pred = {k: np.array(v) for k, v in stores["pos_pred"].items()}
|
||
pos_gt = {k: np.array(v) for k, v in stores["pos_gt"].items()}
|
||
pos_metrics = compute_3d_metrics_for_matched(
|
||
pos_pred, pos_gt, include_orient=False, include_size=False, include_uv=True
|
||
)
|
||
for key in ("depth_abs", "depth_rel", "depth_rmse", "center", "uv"):
|
||
metrics[key] = pos_metrics[key]
|
||
metrics["_pos_matched"] = pos_metrics["matched"]
|
||
|
||
if shape_yaw:
|
||
shape_pred = {k: np.array(v) for k, v in stores["shape_pred"].items()}
|
||
shape_gt = {k: np.array(v) for k, v in stores["shape_gt"].items()}
|
||
metrics["orient"] = compute_orientation_error(shape_pred["yaw"], shape_gt["yaw"])
|
||
metrics["size"] = compute_size_error(shape_pred["dims"], shape_gt["dims"])
|
||
|
||
self.stats_3d["whole"].append(metrics)
|
||
|
||
def _aggregate_face_metric_store(self, pred_store, gt_store, pred_visible_yaw, pred_edge_visible_yaw, gt_visible_target_yaw):
|
||
"""Aggregate face metrics with object-wise visible-orientation accounting."""
|
||
from ultralytics.utils.metrics_3d import (
|
||
compute_3d_metrics_for_matched,
|
||
compute_visible_orientation_metrics,
|
||
empty_3d_metrics,
|
||
)
|
||
|
||
if not pred_store["depth"] and not gt_visible_target_yaw:
|
||
return
|
||
|
||
metrics = empty_3d_metrics(include_orient=False, include_size=True, include_uv=True, include_visible_orient=True)
|
||
if pred_store["depth"]:
|
||
pred_np = {k: np.array(v) for k, v in pred_store.items()}
|
||
gt_np = {k: np.array(v) for k, v in gt_store.items()}
|
||
face_metrics = compute_3d_metrics_for_matched(
|
||
pred_np,
|
||
gt_np,
|
||
include_orient=False,
|
||
include_size=True,
|
||
include_uv=True,
|
||
include_visible_orient=False,
|
||
)
|
||
for key in ("depth_abs", "depth_rel", "depth_rmse", "center", "matched", "uv", "size"):
|
||
metrics[key] = face_metrics[key]
|
||
|
||
if gt_visible_target_yaw:
|
||
metrics.update(
|
||
compute_visible_orientation_metrics(
|
||
np.asarray(pred_visible_yaw, dtype=np.float64),
|
||
np.asarray(pred_edge_visible_yaw, dtype=np.float64),
|
||
np.asarray(gt_visible_target_yaw, dtype=np.float64),
|
||
)
|
||
)
|
||
|
||
self.stats_3d["face"].append(metrics)
|
||
|
||
def _compute_3d_metrics_for_batch(self, preds, batch):
|
||
"""Compute whole-box and face-specific 3D metrics for all images in the batch."""
|
||
from ultralytics.utils.metrics import box_iou
|
||
from ultralytics.utils.plotting_3d import (
|
||
EDGE_YAW_MAX_LATERAL_DIST_M,
|
||
EDGE_YAW_VALID_VISIBILITY_SCORE_THRESH,
|
||
decode_edge_yaw_selection_from_prediction,
|
||
extract_3d_attrs_from_gt,
|
||
extract_3d_attrs_from_prediction,
|
||
is_gt_cut_object,
|
||
select_gt_visible_faces,
|
||
)
|
||
|
||
labels_3d = batch["labels_3d"]
|
||
batch_idx = batch.get("batch_idx")
|
||
batch_cls = batch.get("cls")
|
||
batch_calib = batch.get("calib") # list/tuple of dicts (one per image)
|
||
_, _, img_h, img_w = batch["img"].shape
|
||
|
||
if batch_idx is None or batch_cls is None:
|
||
return
|
||
|
||
if self._preds_3d_filtered is None:
|
||
return
|
||
|
||
face_visibility_score_thresh = float(
|
||
getattr(self.args, "face_visibility_score_thresh", DEFAULT_CFG.face_visibility_score_thresh)
|
||
)
|
||
edge_yaw_compare_score_thresh = float(EDGE_YAW_VALID_VISIBILITY_SCORE_THRESH)
|
||
edge_yaw_compare_max_lateral_dist_m = float(EDGE_YAW_MAX_LATERAL_DIST_M)
|
||
batch_idx_np = batch_idx.cpu().numpy().ravel()
|
||
cls_np = batch_cls.cpu().numpy().ravel()
|
||
labels_3d_np = labels_3d.cpu().numpy()
|
||
|
||
for si, pred in enumerate(preds):
|
||
if not self._is_roi_metric_sample(batch, si):
|
||
continue
|
||
if si >= len(self._preds_3d_filtered):
|
||
continue
|
||
|
||
calib = batch_calib[si] if batch_calib is not None and si < len(batch_calib) else None
|
||
if calib is None:
|
||
continue
|
||
|
||
gt_mask = batch_idx_np == si
|
||
gt_cls = cls_np[gt_mask]
|
||
gt_labels_3d = labels_3d_np[gt_mask]
|
||
|
||
pred_bboxes = pred["bboxes"]
|
||
pred_cls = pred["cls"]
|
||
if pred_bboxes.shape[0] == 0 or gt_cls.shape[0] == 0:
|
||
continue
|
||
|
||
p3d = self._preds_3d_filtered[si].cpu().numpy()
|
||
pedge = None
|
||
if self._preds_edge_filtered is not None:
|
||
pedge_tensor = self._preds_edge_filtered[si]
|
||
pedge = pedge_tensor.cpu().numpy() if pedge_tensor is not None else None
|
||
anchors_np = self._anchors_filtered[si].cpu().numpy()
|
||
strides_np = self._strides_filtered[si].cpu().numpy()
|
||
if p3d.shape[0] != pred_bboxes.shape[0]:
|
||
raise RuntimeError(
|
||
f"3D prediction alignment mismatch for sample {si}: {p3d.shape[0]} 3D rows vs {pred_bboxes.shape[0]} 2D preds"
|
||
)
|
||
|
||
from ultralytics.utils import ops
|
||
|
||
gt_bboxes = batch["bboxes"][gt_mask]
|
||
if gt_bboxes.shape[0] == 0:
|
||
continue
|
||
imgsz = batch["img"].shape[2:]
|
||
gt_bboxes_np = (ops.xywh2xyxy(gt_bboxes) * torch.tensor(imgsz, device=self.device)[[1, 0, 1, 0]]).cpu().numpy()
|
||
gt_bboxes_xyxy = ops.xywh2xyxy(gt_bboxes) * torch.tensor(imgsz, device=self.device)[[1, 0, 1, 0]]
|
||
|
||
iou = box_iou(gt_bboxes_xyxy, pred_bboxes)
|
||
correct_class = torch.tensor(gt_cls, device=self.device)[:, None] == pred_cls
|
||
iou = iou * correct_class
|
||
|
||
iou_np = iou.cpu().numpy()
|
||
matches = np.nonzero(iou_np >= 0.5)
|
||
matches = np.array(matches).T
|
||
if matches.shape[0] == 0:
|
||
continue
|
||
|
||
if matches.shape[0] > 1:
|
||
matches = matches[iou_np[matches[:, 0], matches[:, 1]].argsort()[::-1]]
|
||
matches = matches[np.unique(matches[:, 1], return_index=True)[1]]
|
||
matches = matches[np.unique(matches[:, 0], return_index=True)[1]]
|
||
|
||
whole_store = self._whole_metric_store()
|
||
face_pred = self._metric_store()
|
||
face_gt = self._metric_store()
|
||
visible_pred_yaw = []
|
||
visible_pred_edge_yaw = []
|
||
visible_gt_target_yaw = []
|
||
|
||
for gi, pi in matches:
|
||
cls_id = int(gt_cls[gi])
|
||
gt_bbox_xyxy = gt_bboxes_np[gi]
|
||
pred_bbox_xyxy = pred_bboxes[pi].cpu().numpy()
|
||
pred_whole = None
|
||
gt_whole = extract_3d_attrs_from_gt(
|
||
gt_labels_3d[gi],
|
||
cls_id,
|
||
calib,
|
||
img_w,
|
||
img_h,
|
||
self.face_3d_classes,
|
||
self.complete_3d_classes,
|
||
score_thr=face_visibility_score_thresh,
|
||
)
|
||
if gt_whole is not None:
|
||
pred_whole = extract_3d_attrs_from_prediction(
|
||
p3d[pi],
|
||
anchors_np[:, pi],
|
||
strides_np[pi],
|
||
calib,
|
||
pred_edge_60=pedge[pi] if pedge is not None else None,
|
||
)
|
||
self._append_metric_attr(whole_store["shape_gt"], gt_whole)
|
||
self._append_metric_attr(whole_store["shape_pred"], pred_whole)
|
||
|
||
if cls_id in self.complete_3d_classes or not is_gt_cut_object(gt_labels_3d[gi]):
|
||
self._append_metric_attr(whole_store["pos_gt"], gt_whole)
|
||
self._append_metric_attr(whole_store["pos_pred"], pred_whole)
|
||
|
||
if cls_id not in self.face_3d_classes:
|
||
continue
|
||
gt_visible_faces = select_gt_visible_faces(gt_labels_3d[gi], score_thr=face_visibility_score_thresh)
|
||
if gt_whole is not None and pred_whole is not None:
|
||
edge_selection = decode_edge_yaw_selection_from_prediction(
|
||
p3d[pi],
|
||
pedge[pi] if pedge is not None else None,
|
||
anchors_np[:, pi],
|
||
strides_np[pi],
|
||
calib,
|
||
score_thr=edge_yaw_compare_score_thresh,
|
||
bbox_xyxy=pred_bbox_xyxy,
|
||
img_w=img_w,
|
||
img_h=img_h,
|
||
max_lateral_dist_m=edge_yaw_compare_max_lateral_dist_m,
|
||
)
|
||
visible_pred_yaw.append(pred_whole["yaw"])
|
||
visible_pred_edge_yaw.append(edge_selection["yaw"] if edge_selection.get("is_valid") else float("nan"))
|
||
visible_gt_target_yaw.append(gt_whole["yaw"])
|
||
|
||
for face_type, _ in gt_visible_faces:
|
||
gt_face = extract_3d_attrs_from_gt(
|
||
gt_labels_3d[gi],
|
||
cls_id,
|
||
calib,
|
||
img_w,
|
||
img_h,
|
||
self.face_3d_classes,
|
||
self.complete_3d_classes,
|
||
face_type=face_type,
|
||
score_thr=face_visibility_score_thresh,
|
||
)
|
||
pred_face = extract_3d_attrs_from_prediction(
|
||
p3d[pi],
|
||
anchors_np[:, pi],
|
||
strides_np[pi],
|
||
calib,
|
||
face_type=face_type,
|
||
pred_edge_60=pedge[pi] if pedge is not None else None,
|
||
)
|
||
if gt_face is None or pred_face is None:
|
||
continue
|
||
self._append_metric_attr(face_gt, gt_face)
|
||
self._append_metric_attr(face_pred, pred_face)
|
||
|
||
self._aggregate_whole_metric_store(whole_store)
|
||
self._aggregate_face_metric_store(face_pred, face_gt, visible_pred_yaw, visible_pred_edge_yaw, visible_gt_target_yaw)
|
||
|
||
def get_stats(self):
|
||
"""Get 2D stats and merge grouped 3D metrics."""
|
||
from ultralytics.utils.metrics_3d import aggregate_3d_metric_groups
|
||
|
||
stats = super().get_stats()
|
||
metrics_3d = aggregate_3d_metric_groups(self.stats_3d)
|
||
self.metrics_3d_results = metrics_3d
|
||
|
||
whole = metrics_3d["whole"]
|
||
for key, value in whole.items():
|
||
stats[f"metrics_3d/{key}"] = value
|
||
|
||
face = metrics_3d["face"]
|
||
for key, value in face.items():
|
||
stats[f"metrics_3d_face/{key}"] = value
|
||
|
||
self.stats_3d = {"whole": [], "face": []} # Reset for next epoch
|
||
return stats
|
||
|
||
def print_results(self):
|
||
"""Print 3D physical metrics first, followed by the 2D detection summary."""
|
||
metrics_3d = self.metrics_3d_results
|
||
whole = metrics_3d["whole"]
|
||
face = metrics_3d["face"]
|
||
trainer = getattr(self, "trainer", None)
|
||
loss_ref_names = getattr(trainer, "loss_names", None) or (
|
||
"box",
|
||
"cls",
|
||
"dfl",
|
||
"z3d",
|
||
"uv",
|
||
"size",
|
||
"ycls",
|
||
"ydeg",
|
||
"ccls",
|
||
"fz",
|
||
"fuv",
|
||
"fsize",
|
||
"fcls",
|
||
"euv",
|
||
"ez",
|
||
)
|
||
leadw, colw = 7, 9
|
||
|
||
def _fmt_3d(value) -> str:
|
||
"""Format 3D metrics, showing '-' when the metric is not valid."""
|
||
return "-" if not np.isfinite(value) else f"{value:.3g}"
|
||
|
||
row_3d_values = [
|
||
"",
|
||
"all-3d",
|
||
f"{whole['matched']}",
|
||
f"{face['matched']}",
|
||
"-",
|
||
_fmt_3d(whole["depth_abs"]),
|
||
_fmt_3d(whole["uv"]),
|
||
_fmt_3d(whole["size"]),
|
||
"-",
|
||
_fmt_3d(whole["orient"]),
|
||
"-",
|
||
_fmt_3d(face["depth_abs"]),
|
||
_fmt_3d(face["uv"]),
|
||
_fmt_3d(face["size"]),
|
||
"-",
|
||
_fmt_3d(face["edge_orient_visible"]),
|
||
]
|
||
row_3d_values.extend(["-"] * (1 + len(loss_ref_names) - len(row_3d_values)))
|
||
row_3d = (f"%{leadw}s" + f"%{colw}s" * len(loss_ref_names)) % tuple(
|
||
row_3d_values[: 1 + len(loss_ref_names)]
|
||
)
|
||
header_2d = (f"%{leadw}s" + f"%{colw}s" * 7) % ("", "Class", "Imgs", "Inst", "P", "R", "m50", "m50-95")
|
||
row_2d = (f"%{leadw}s" + f"%{colw}s" + f"%{colw}i" * 2 + f"%{colw}.3g" * len(self.metrics.keys)) % (
|
||
"",
|
||
"all-2d",
|
||
self.seen,
|
||
self.metrics.nt_per_class.sum(),
|
||
*self.metrics.mean_results(),
|
||
)
|
||
LOGGER.info(row_3d)
|
||
LOGGER.info(header_2d)
|
||
LOGGER.info(row_2d)
|
||
if self.metrics.nt_per_class.sum() == 0:
|
||
LOGGER.warning(f"no labels found in {self.args.task} set, cannot compute metrics without labels")
|
||
|
||
if self.args.verbose and not self.training and self.nc > 1 and len(self.metrics.stats):
|
||
pf_class = "%22s" + "%11i" * 2 + "%11.3g" * len(self.metrics.keys) + "%11s" * 8
|
||
blanks = ("", "", "", "", "", "", "", "")
|
||
for i, c in enumerate(self.metrics.ap_class_index):
|
||
LOGGER.info(
|
||
pf_class
|
||
% (
|
||
self.names[c],
|
||
self.metrics.nt_per_image[c],
|
||
self.metrics.nt_per_class[c],
|
||
*self.metrics.class_result(i),
|
||
*blanks,
|
||
)
|
||
)
|