Files
yolov26_3d/ultralytics/models/yolo/detect/train.py
2026-06-24 09:35:46 +08:00

1289 lines
56 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,
)
)