单目3D初始代码
This commit is contained in:
1485
ultralytics/utils/__init__.py
Executable file
1485
ultralytics/utils/__init__.py
Executable file
File diff suppressed because it is too large
Load Diff
119
ultralytics/utils/autobatch.py
Executable file
119
ultralytics/utils/autobatch.py
Executable file
@@ -0,0 +1,119 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
"""Functions for estimating the best YOLO batch size to use a fraction of the available CUDA memory in PyTorch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ultralytics.utils import DEFAULT_CFG, LOGGER, colorstr
|
||||
from ultralytics.utils.torch_utils import autocast, profile_ops
|
||||
|
||||
|
||||
def check_train_batch_size(
|
||||
model: torch.nn.Module,
|
||||
imgsz: int = 640,
|
||||
amp: bool = True,
|
||||
batch: int | float = -1,
|
||||
max_num_obj: int = 1,
|
||||
) -> int:
|
||||
"""Compute optimal YOLO training batch size using the autobatch() function.
|
||||
|
||||
Args:
|
||||
model (torch.nn.Module): YOLO model to check batch size for.
|
||||
imgsz (int, optional): Image size used for training.
|
||||
amp (bool, optional): Use automatic mixed precision if True.
|
||||
batch (int | float, optional): Fraction of GPU memory to use. If -1, use default.
|
||||
max_num_obj (int, optional): The maximum number of objects from dataset.
|
||||
|
||||
Returns:
|
||||
(int): Optimal batch size computed using the autobatch() function.
|
||||
|
||||
Notes:
|
||||
If 0.0 < batch < 1.0, it's used as the fraction of GPU memory to use.
|
||||
Otherwise, a default fraction of 0.6 is used.
|
||||
"""
|
||||
with autocast(enabled=amp):
|
||||
return autobatch(
|
||||
deepcopy(model).train(), imgsz, fraction=batch if 0.0 < batch < 1.0 else 0.6, max_num_obj=max_num_obj
|
||||
)
|
||||
|
||||
|
||||
def autobatch(
|
||||
model: torch.nn.Module,
|
||||
imgsz: int = 640,
|
||||
fraction: float = 0.60,
|
||||
batch_size: int = DEFAULT_CFG.batch,
|
||||
max_num_obj: int = 1,
|
||||
) -> int:
|
||||
"""Automatically estimate the best YOLO batch size to use a fraction of the available CUDA memory.
|
||||
|
||||
Args:
|
||||
model (torch.nn.Module): YOLO model to compute batch size for.
|
||||
imgsz (int, optional): The image size used as input for the YOLO model.
|
||||
fraction (float, optional): The fraction of available CUDA memory to use.
|
||||
batch_size (int, optional): The default batch size to use if an error is detected.
|
||||
max_num_obj (int, optional): The maximum number of objects from dataset.
|
||||
|
||||
Returns:
|
||||
(int): The optimal batch size.
|
||||
"""
|
||||
# Check device
|
||||
prefix = colorstr("AutoBatch: ")
|
||||
LOGGER.info(f"{prefix}Computing optimal batch size for imgsz={imgsz} at {fraction * 100}% CUDA memory utilization.")
|
||||
device = next(model.parameters()).device # get model device
|
||||
if device.type in {"cpu", "mps"}:
|
||||
LOGGER.warning(f"{prefix}intended for CUDA devices, using default batch-size {batch_size}")
|
||||
return batch_size
|
||||
if torch.backends.cudnn.benchmark:
|
||||
LOGGER.warning(f"{prefix}Requires torch.backends.cudnn.benchmark=False, using default batch-size {batch_size}")
|
||||
return batch_size
|
||||
|
||||
# Inspect CUDA memory
|
||||
gb = 1 << 30 # bytes to GiB (1024 ** 3)
|
||||
d = f"CUDA:{os.getenv('CUDA_VISIBLE_DEVICES', '0').strip()[0]}" # 'CUDA:0'
|
||||
properties = torch.cuda.get_device_properties(device) # device properties
|
||||
t = properties.total_memory / gb # GiB total
|
||||
r = torch.cuda.memory_reserved(device) / gb # GiB reserved
|
||||
a = torch.cuda.memory_allocated(device) / gb # GiB allocated
|
||||
f = t - (r + a) # GiB free
|
||||
LOGGER.info(f"{prefix}{d} ({properties.name}) {t:.2f}G total, {r:.2f}G reserved, {a:.2f}G allocated, {f:.2f}G free")
|
||||
|
||||
# Profile batch sizes
|
||||
batch_sizes = [1, 2, 4, 8, 16] if t < 16 else [1, 2, 4, 8, 16, 32, 64]
|
||||
ch = model.yaml.get("channels", 3)
|
||||
try:
|
||||
img = [torch.empty(b, ch, imgsz, imgsz) for b in batch_sizes]
|
||||
results = profile_ops(img, model, n=1, device=device, max_num_obj=max_num_obj)
|
||||
|
||||
# Fit a solution
|
||||
xy = [
|
||||
[x, y[2]]
|
||||
for i, (x, y) in enumerate(zip(batch_sizes, results))
|
||||
if y # valid result
|
||||
and isinstance(y[2], (int, float)) # is numeric
|
||||
and 0 < y[2] < t # between 0 and GPU limit
|
||||
and (i == 0 or not results[i - 1] or y[2] > results[i - 1][2]) # first item or increasing memory
|
||||
]
|
||||
fit_x, fit_y = zip(*xy) if xy else ([], [])
|
||||
p = np.polyfit(fit_x, fit_y, deg=1) # first-degree polynomial fit in log space
|
||||
b = int((round(f * fraction) - p[1]) / p[0]) # y intercept (optimal batch size)
|
||||
if None in results: # some sizes failed
|
||||
i = results.index(None) # first fail index
|
||||
if b >= batch_sizes[i]: # y intercept above failure point
|
||||
b = batch_sizes[max(i - 1, 0)] # select prior safe point
|
||||
if b < 1 or b > 1024: # b outside of safe range
|
||||
LOGGER.warning(f"{prefix}batch={b} outside safe range, using default batch-size {batch_size}.")
|
||||
b = batch_size
|
||||
|
||||
fraction = (np.polyval(p, b) + r + a) / t # predicted fraction
|
||||
LOGGER.info(f"{prefix}Using batch-size {b} for {d} {t * fraction:.2f}G/{t:.2f}G ({fraction * 100:.0f}%) ✅")
|
||||
return b
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{prefix}error detected: {e}, using default batch-size {batch_size}.")
|
||||
return batch_size
|
||||
finally:
|
||||
torch.cuda.empty_cache()
|
||||
208
ultralytics/utils/autodevice.py
Executable file
208
ultralytics/utils/autodevice.py
Executable file
@@ -0,0 +1,208 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from ultralytics.utils import LOGGER
|
||||
from ultralytics.utils.checks import check_requirements
|
||||
|
||||
|
||||
class GPUInfo:
|
||||
"""Manages NVIDIA GPU information via pynvml with robust error handling.
|
||||
|
||||
Provides methods to query detailed GPU statistics (utilization, memory, temp, power) and select the most idle GPUs
|
||||
based on configurable criteria. It safely handles the absence or initialization failure of the pynvml library by
|
||||
logging warnings and disabling related features, preventing application crashes.
|
||||
|
||||
Includes fallback logic using `torch.cuda` for basic device counting if NVML is unavailable during GPU
|
||||
selection. Manages NVML initialization and shutdown internally.
|
||||
|
||||
Attributes:
|
||||
pynvml (module | None): The `pynvml` module if successfully imported and initialized, otherwise `None`.
|
||||
nvml_available (bool): Indicates if `pynvml` is ready for use. True if import and `nvmlInit()` succeeded, False
|
||||
otherwise.
|
||||
gpu_stats (list[dict[str, Any]]): A list of dictionaries, each holding stats for one GPU, populated on
|
||||
initialization and by `refresh_stats()`. Keys include: 'index', 'name', 'utilization' (%), 'memory_used' (MiB),
|
||||
'memory_total' (MiB), 'memory_free' (MiB), 'temperature' (C), 'power_draw' (W), 'power_limit' (W or 'N/A').
|
||||
Empty if NVML is unavailable or queries fail.
|
||||
|
||||
Methods:
|
||||
refresh_stats: Refresh the internal gpu_stats list by querying NVML.
|
||||
print_status: Print GPU status in a compact table format using current stats.
|
||||
select_idle_gpu: Select the most idle GPUs based on utilization and free memory.
|
||||
shutdown: Shut down NVML if it was initialized.
|
||||
|
||||
Examples:
|
||||
Initialize GPUInfo and print status
|
||||
>>> gpu_info = GPUInfo()
|
||||
>>> gpu_info.print_status()
|
||||
|
||||
Select idle GPUs with minimum memory requirements
|
||||
>>> selected = gpu_info.select_idle_gpu(count=2, min_memory_fraction=0.2)
|
||||
>>> print(f"Selected GPU indices: {selected}")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize GPUInfo, attempting to import and initialize pynvml."""
|
||||
self.pynvml: Any | None = None
|
||||
self.nvml_available: bool = False
|
||||
self.gpu_stats: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
check_requirements("nvidia-ml-py>=12.0.0")
|
||||
self.pynvml = __import__("pynvml")
|
||||
self.pynvml.nvmlInit()
|
||||
self.nvml_available = True
|
||||
self.refresh_stats()
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Failed to initialize pynvml, GPU stats disabled: {e}")
|
||||
|
||||
def __del__(self):
|
||||
"""Ensure NVML is shut down when the object is garbage collected."""
|
||||
self.shutdown()
|
||||
|
||||
def shutdown(self):
|
||||
"""Shut down NVML if it was initialized."""
|
||||
if self.nvml_available and self.pynvml:
|
||||
try:
|
||||
self.pynvml.nvmlShutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self.nvml_available = False
|
||||
|
||||
def refresh_stats(self):
|
||||
"""Refresh the internal gpu_stats list by querying NVML."""
|
||||
self.gpu_stats = []
|
||||
if not self.nvml_available or not self.pynvml:
|
||||
return
|
||||
|
||||
try:
|
||||
device_count = self.pynvml.nvmlDeviceGetCount()
|
||||
self.gpu_stats.extend(self._get_device_stats(i) for i in range(device_count))
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Error during device query: {e}")
|
||||
self.gpu_stats = []
|
||||
|
||||
def _get_device_stats(self, index: int) -> dict[str, Any]:
|
||||
"""Get stats for a single GPU device."""
|
||||
handle = self.pynvml.nvmlDeviceGetHandleByIndex(index)
|
||||
memory = self.pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||
util = self.pynvml.nvmlDeviceGetUtilizationRates(handle)
|
||||
|
||||
def safe_get(func, *args, default=-1, divisor=1):
|
||||
try:
|
||||
val = func(*args)
|
||||
return val // divisor if divisor != 1 and isinstance(val, (int, float)) else val
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
temp_type = getattr(self.pynvml, "NVML_TEMPERATURE_GPU", -1)
|
||||
|
||||
return {
|
||||
"index": index,
|
||||
"name": self.pynvml.nvmlDeviceGetName(handle),
|
||||
"utilization": util.gpu if util else -1,
|
||||
"memory_used": memory.used >> 20 if memory else -1, # Convert bytes to MiB
|
||||
"memory_total": memory.total >> 20 if memory else -1,
|
||||
"memory_free": memory.free >> 20 if memory else -1,
|
||||
"temperature": safe_get(self.pynvml.nvmlDeviceGetTemperature, handle, temp_type),
|
||||
"power_draw": safe_get(self.pynvml.nvmlDeviceGetPowerUsage, handle, divisor=1000), # Convert mW to W
|
||||
"power_limit": safe_get(self.pynvml.nvmlDeviceGetEnforcedPowerLimit, handle, divisor=1000),
|
||||
}
|
||||
|
||||
def print_status(self):
|
||||
"""Print GPU status in a compact table format using current stats."""
|
||||
self.refresh_stats()
|
||||
if not self.gpu_stats:
|
||||
LOGGER.warning("No GPU stats available.")
|
||||
return
|
||||
|
||||
stats = self.gpu_stats
|
||||
name_len = max(len(gpu.get("name", "N/A")) for gpu in stats)
|
||||
hdr = f"{'Idx':<3} {'Name':<{name_len}} {'Util':>6} {'Mem (MiB)':>15} {'Temp':>5} {'Pwr (W)':>10}"
|
||||
LOGGER.info(f"\n--- GPU Status ---\n{hdr}\n{'-' * len(hdr)}")
|
||||
|
||||
for gpu in stats:
|
||||
u = f"{gpu['utilization']:>5}%" if gpu["utilization"] >= 0 else " N/A "
|
||||
m = f"{gpu['memory_used']:>6}/{gpu['memory_total']:<6}" if gpu["memory_used"] >= 0 else " N/A / N/A "
|
||||
t = f"{gpu['temperature']}C" if gpu["temperature"] >= 0 else " N/A "
|
||||
p = f"{gpu['power_draw']:>3}/{gpu['power_limit']:<3}" if gpu["power_draw"] >= 0 else " N/A "
|
||||
|
||||
LOGGER.info(f"{gpu.get('index'):<3d} {gpu.get('name', 'N/A'):<{name_len}} {u:>6} {m:>15} {t:>5} {p:>10}")
|
||||
|
||||
LOGGER.info(f"{'-' * len(hdr)}\n")
|
||||
|
||||
def select_idle_gpu(
|
||||
self, count: int = 1, min_memory_fraction: float = 0, min_util_fraction: float = 0
|
||||
) -> list[int]:
|
||||
"""Select the most idle GPUs based on utilization and free memory.
|
||||
|
||||
Args:
|
||||
count (int): The number of idle GPUs to select.
|
||||
min_memory_fraction (float): Minimum free memory required as a fraction of total memory.
|
||||
min_util_fraction (float): Minimum free utilization rate required from 0.0 - 1.0.
|
||||
|
||||
Returns:
|
||||
(list[int]): Indices of the selected GPUs, sorted by idleness (lowest utilization first).
|
||||
|
||||
Notes:
|
||||
Returns fewer than 'count' if not enough qualify or exist.
|
||||
Returns empty list if NVML stats are unavailable or no GPUs meet the criteria.
|
||||
"""
|
||||
assert min_memory_fraction <= 1.0, f"min_memory_fraction must be <= 1.0, got {min_memory_fraction}"
|
||||
assert min_util_fraction <= 1.0, f"min_util_fraction must be <= 1.0, got {min_util_fraction}"
|
||||
criteria = (
|
||||
f"free memory >= {min_memory_fraction * 100:.1f}% and free utilization >= {min_util_fraction * 100:.1f}%"
|
||||
)
|
||||
LOGGER.info(f"Searching for {count} idle GPUs with {criteria}...")
|
||||
|
||||
if count <= 0:
|
||||
return []
|
||||
|
||||
self.refresh_stats()
|
||||
if not self.gpu_stats:
|
||||
LOGGER.warning("NVML stats unavailable.")
|
||||
return []
|
||||
|
||||
# Filter and sort eligible GPUs
|
||||
eligible_gpus = [
|
||||
gpu
|
||||
for gpu in self.gpu_stats
|
||||
if gpu.get("memory_free", 0) / gpu.get("memory_total", 1) >= min_memory_fraction
|
||||
and (100 - gpu.get("utilization", 100)) >= min_util_fraction * 100
|
||||
]
|
||||
# Random tiebreaker prevents race conditions when multiple processes start simultaneously
|
||||
# and all GPUs appear equally idle (same utilization and free memory)
|
||||
eligible_gpus.sort(key=lambda x: (x.get("utilization", 101), -x.get("memory_free", 0), random.random()))
|
||||
|
||||
# Select top 'count' indices
|
||||
selected = [gpu["index"] for gpu in eligible_gpus[:count]]
|
||||
|
||||
if selected:
|
||||
if len(selected) < count:
|
||||
LOGGER.warning(f"Requested {count} GPUs but only {len(selected)} met the idle criteria.")
|
||||
LOGGER.info(f"Selected idle CUDA devices {selected}")
|
||||
else:
|
||||
LOGGER.warning(f"No GPUs met criteria ({criteria}).")
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
required_free_mem_fraction = 0.2 # Require 20% free VRAM
|
||||
required_free_util_fraction = 0.2 # Require 20% free utilization
|
||||
num_gpus_to_select = 1
|
||||
|
||||
gpu_info = GPUInfo()
|
||||
gpu_info.print_status()
|
||||
|
||||
if selected := gpu_info.select_idle_gpu(
|
||||
count=num_gpus_to_select,
|
||||
min_memory_fraction=required_free_mem_fraction,
|
||||
min_util_fraction=required_free_util_fraction,
|
||||
):
|
||||
print(f"\n==> Using selected GPU indices: {selected}")
|
||||
devices = [f"cuda:{idx}" for idx in selected]
|
||||
print(f" Target devices: {devices}")
|
||||
718
ultralytics/utils/benchmarks.py
Executable file
718
ultralytics/utils/benchmarks.py
Executable file
@@ -0,0 +1,718 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
"""
|
||||
Benchmark YOLO model formats for speed and accuracy.
|
||||
|
||||
Usage:
|
||||
from ultralytics.utils.benchmarks import ProfileModels, benchmark
|
||||
ProfileModels(['yolo26n.yaml', 'yolov8s.yaml']).run()
|
||||
benchmark(model='yolo26n.pt', imgsz=160)
|
||||
|
||||
Format | `format=argument` | Model
|
||||
--- | --- | ---
|
||||
PyTorch | - | yolo26n.pt
|
||||
TorchScript | `torchscript` | yolo26n.torchscript
|
||||
ONNX | `onnx` | yolo26n.onnx
|
||||
OpenVINO | `openvino` | yolo26n_openvino_model/
|
||||
TensorRT | `engine` | yolo26n.engine
|
||||
CoreML | `coreml` | yolo26n.mlpackage
|
||||
TensorFlow SavedModel | `saved_model` | yolo26n_saved_model/
|
||||
TensorFlow GraphDef | `pb` | yolo26n.pb
|
||||
TensorFlow Lite | `tflite` | yolo26n.tflite
|
||||
TensorFlow Edge TPU | `edgetpu` | yolo26n_edgetpu.tflite
|
||||
TensorFlow.js | `tfjs` | yolo26n_web_model/
|
||||
PaddlePaddle | `paddle` | yolo26n_paddle_model/
|
||||
MNN | `mnn` | yolo26n.mnn
|
||||
NCNN | `ncnn` | yolo26n_ncnn_model/
|
||||
IMX | `imx` | yolo26n_imx_model/
|
||||
RKNN | `rknn` | yolo26n_rknn_model/
|
||||
ExecuTorch | `executorch` | yolo26n_executorch_model/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch.cuda
|
||||
|
||||
from ultralytics import YOLO, YOLOWorld
|
||||
from ultralytics.cfg import TASK2DATA, TASK2METRIC
|
||||
from ultralytics.engine.exporter import export_formats
|
||||
from ultralytics.utils import ARM64, ASSETS, ASSETS_URL, IS_JETSON, LINUX, LOGGER, MACOS, TQDM, WEIGHTS_DIR, YAML
|
||||
from ultralytics.utils.checks import IS_PYTHON_3_13, check_imgsz, check_requirements, check_yolo, is_rockchip
|
||||
from ultralytics.utils.downloads import safe_download
|
||||
from ultralytics.utils.files import file_size
|
||||
from ultralytics.utils.torch_utils import get_cpu_info, select_device
|
||||
|
||||
|
||||
def benchmark(
|
||||
model=WEIGHTS_DIR / "yolo26n.pt",
|
||||
data=None,
|
||||
imgsz=160,
|
||||
half=False,
|
||||
int8=False,
|
||||
device="cpu",
|
||||
verbose=False,
|
||||
eps=1e-3,
|
||||
format="",
|
||||
**kwargs,
|
||||
):
|
||||
"""Benchmark a YOLO model across different formats for speed and accuracy.
|
||||
|
||||
Args:
|
||||
model (str | Path): Path to the model file or directory.
|
||||
data (str | None): Dataset to evaluate on, inherited from TASK2DATA if not passed.
|
||||
imgsz (int): Image size for the benchmark.
|
||||
half (bool): Use half-precision for the model if True.
|
||||
int8 (bool): Use int8-precision for the model if True.
|
||||
device (str): Device to run the benchmark on, either 'cpu' or 'cuda'.
|
||||
verbose (bool | float): If True or a float, assert benchmarks pass with given metric.
|
||||
eps (float): Epsilon value for divide by zero prevention.
|
||||
format (str): Export format for benchmarking. If not supplied all formats are benchmarked.
|
||||
**kwargs (Any): Additional keyword arguments for exporter.
|
||||
|
||||
Returns:
|
||||
(polars.DataFrame): A Polars DataFrame with benchmark results for each format, including file size, metric, and
|
||||
inference time.
|
||||
|
||||
Examples:
|
||||
Benchmark a YOLO model with default settings:
|
||||
>>> from ultralytics.utils.benchmarks import benchmark
|
||||
>>> benchmark(model="yolo26n.pt", imgsz=640)
|
||||
"""
|
||||
imgsz = check_imgsz(imgsz)
|
||||
assert imgsz[0] == imgsz[1] if isinstance(imgsz, list) else True, "benchmark() only supports square imgsz."
|
||||
|
||||
import polars as pl # scope for faster 'import ultralytics'
|
||||
|
||||
pl.Config.set_tbl_cols(-1) # Show all columns
|
||||
pl.Config.set_tbl_rows(-1) # Show all rows
|
||||
pl.Config.set_tbl_width_chars(-1) # No width limit
|
||||
pl.Config.set_tbl_hide_column_data_types(True) # Hide data types
|
||||
pl.Config.set_tbl_hide_dataframe_shape(True) # Hide shape info
|
||||
pl.Config.set_tbl_formatting("ASCII_BORDERS_ONLY_CONDENSED")
|
||||
|
||||
device = select_device(device, verbose=False)
|
||||
if isinstance(model, (str, Path)):
|
||||
model = YOLO(model)
|
||||
data = data or TASK2DATA[model.task] # task to dataset, i.e. coco8.yaml for task=detect
|
||||
key = TASK2METRIC[model.task] # task to metric, i.e. metrics/mAP50-95(B) for task=detect
|
||||
|
||||
y = []
|
||||
t0 = time.time()
|
||||
|
||||
format_arg = format.lower()
|
||||
if format_arg:
|
||||
formats = frozenset(export_formats()["Argument"])
|
||||
assert format in formats, f"Expected format to be one of {formats}, but got '{format_arg}'."
|
||||
for name, format, suffix, cpu, gpu, _ in zip(*export_formats().values()):
|
||||
emoji, filename = "❌", None # export defaults
|
||||
try:
|
||||
if format_arg and format_arg != format:
|
||||
continue
|
||||
|
||||
# Checks
|
||||
if format == "pb":
|
||||
assert model.task != "obb", "TensorFlow GraphDef not supported for OBB task"
|
||||
elif format == "edgetpu":
|
||||
assert LINUX and not ARM64, "Edge TPU export only supported on non-aarch64 Linux"
|
||||
elif format in {"coreml", "tfjs"}:
|
||||
assert MACOS or (LINUX and not ARM64), (
|
||||
"CoreML and TF.js export only supported on macOS and non-aarch64 Linux"
|
||||
)
|
||||
if format == "coreml":
|
||||
assert not IS_PYTHON_3_13, "CoreML not supported on Python 3.13"
|
||||
if format in {"saved_model", "pb", "tflite", "edgetpu", "tfjs"}:
|
||||
assert not isinstance(model, YOLOWorld), "YOLOWorldv2 TensorFlow exports not supported by onnx2tf yet"
|
||||
# assert not IS_PYTHON_MINIMUM_3_12, "TFLite exports not supported on Python>=3.12 yet"
|
||||
if format == "paddle":
|
||||
assert not isinstance(model, YOLOWorld), "YOLOWorldv2 Paddle exports not supported yet"
|
||||
assert model.task != "obb", "Paddle OBB bug https://github.com/PaddlePaddle/Paddle/issues/72024"
|
||||
assert (LINUX and not IS_JETSON) or MACOS, "Windows and Jetson Paddle exports not supported yet"
|
||||
if format == "mnn":
|
||||
assert not isinstance(model, YOLOWorld), "YOLOWorldv2 MNN exports not supported yet"
|
||||
if format == "ncnn":
|
||||
assert not isinstance(model, YOLOWorld), "YOLOWorldv2 NCNN exports not supported yet"
|
||||
if format == "imx":
|
||||
assert not isinstance(model, YOLOWorld), "YOLOWorldv2 IMX exports not supported"
|
||||
assert model.task in {"detect", "classify", "pose", "segment"}, (
|
||||
"IMX export is only supported for detection, classification, pose estimation and segmentation tasks"
|
||||
)
|
||||
assert "C2f" in model.__str__(), "IMX only supported for YOLOv8n and YOLO11n"
|
||||
if format == "rknn":
|
||||
assert not isinstance(model, YOLOWorld), "YOLOWorldv2 RKNN exports not supported yet"
|
||||
assert LINUX, "RKNN only supported on Linux"
|
||||
assert not is_rockchip(), "RKNN Inference only supported on Rockchip devices"
|
||||
if format == "executorch":
|
||||
assert not isinstance(model, YOLOWorld), "YOLOWorldv2 ExecuTorch exports not supported yet"
|
||||
if "cpu" in device.type:
|
||||
assert cpu, "inference not supported on CPU"
|
||||
if "cuda" in device.type:
|
||||
assert gpu, "inference not supported on GPU"
|
||||
|
||||
# Export
|
||||
if format == "-":
|
||||
filename = model.pt_path or model.ckpt_path or model.model_name
|
||||
exported_model = deepcopy(model) # PyTorch format
|
||||
else:
|
||||
filename = deepcopy(model).export(
|
||||
imgsz=imgsz, format=format, half=half, int8=int8, data=data, device=device, verbose=False, **kwargs
|
||||
)
|
||||
exported_model = YOLO(filename, task=model.task)
|
||||
assert suffix in str(filename), "export failed"
|
||||
emoji = "❎" # indicates export succeeded
|
||||
|
||||
# Predict
|
||||
assert model.task != "pose" or format != "pb", "GraphDef Pose inference is not supported"
|
||||
assert format not in {"edgetpu", "tfjs"}, "inference not supported"
|
||||
assert format != "coreml" or platform.system() == "Darwin", "inference only supported on macOS>=10.13"
|
||||
exported_model.predict(ASSETS / "bus.jpg", imgsz=imgsz, device=device, half=half, verbose=False)
|
||||
|
||||
# Validate
|
||||
results = exported_model.val(
|
||||
data=data,
|
||||
batch=1,
|
||||
imgsz=imgsz,
|
||||
plots=False,
|
||||
device=device,
|
||||
half=half,
|
||||
int8=int8,
|
||||
verbose=False,
|
||||
conf=0.001, # all the pre-set benchmark mAP values are based on conf=0.001
|
||||
)
|
||||
metric, speed = results.results_dict[key], results.speed["inference"]
|
||||
fps = round(1000 / (speed + eps), 2) # frames per second
|
||||
y.append([name, "✅", round(file_size(filename), 1), round(metric, 4), round(speed, 2), fps])
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
assert type(e) is AssertionError, f"Benchmark failure for {name}: {e}"
|
||||
LOGGER.error(f"Benchmark failure for {name}: {e}")
|
||||
y.append([name, emoji, round(file_size(filename), 1), None, None, None]) # mAP, t_inference
|
||||
|
||||
# Print results
|
||||
check_yolo(device=device) # print system info
|
||||
df = pl.DataFrame(y, schema=["Format", "Status❔", "Size (MB)", key, "Inference time (ms/im)", "FPS"], orient="row")
|
||||
df = df.with_row_index(" ", offset=1) # add index info
|
||||
df_display = df.with_columns(pl.all().cast(pl.String).fill_null("-"))
|
||||
|
||||
name = model.model_name
|
||||
dt = time.time() - t0
|
||||
legend = "Benchmarks legend: - ✅ Success - ❎ Export passed but validation failed - ❌️ Export failed"
|
||||
s = f"\nBenchmarks complete for {name} on {data} at imgsz={imgsz} ({dt:.2f}s)\n{legend}\n{df_display}\n"
|
||||
LOGGER.info(s)
|
||||
with open("benchmarks.log", "a", errors="ignore", encoding="utf-8") as f:
|
||||
f.write(s)
|
||||
|
||||
if verbose and isinstance(verbose, float):
|
||||
metrics = df[key].to_numpy() # values to compare to floor
|
||||
floor = verbose # minimum metric floor to pass, i.e. = 0.29 mAP for YOLOv5n
|
||||
assert all(x > floor for x in metrics if not np.isnan(x)), f"Benchmark failure: metric(s) < floor {floor}"
|
||||
|
||||
return df_display
|
||||
|
||||
|
||||
class RF100Benchmark:
|
||||
"""Benchmark YOLO model performance on the RF100 dataset collection.
|
||||
|
||||
This class provides functionality to download, process, and evaluate YOLO models on the RF100 datasets.
|
||||
|
||||
Attributes:
|
||||
ds_names (list[str]): Names of datasets used for benchmarking.
|
||||
ds_cfg_list (list[Path]): List of paths to dataset configuration files.
|
||||
rf (Roboflow | None): Roboflow instance for accessing datasets.
|
||||
val_metrics (list[str]): Metrics used for validation.
|
||||
|
||||
Methods:
|
||||
set_key: Set Roboflow API key for accessing datasets.
|
||||
parse_dataset: Parse dataset links and download datasets.
|
||||
fix_yaml: Fix train and validation paths in YAML files.
|
||||
evaluate: Evaluate model performance on validation results.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the RF100Benchmark class for benchmarking YOLO model performance on RF100 datasets."""
|
||||
self.ds_names = []
|
||||
self.ds_cfg_list = []
|
||||
self.rf = None
|
||||
self.val_metrics = ["class", "images", "targets", "precision", "recall", "map50", "map95"]
|
||||
|
||||
def set_key(self, api_key: str):
|
||||
"""Set Roboflow API key for processing.
|
||||
|
||||
Args:
|
||||
api_key (str): The API key.
|
||||
|
||||
Examples:
|
||||
Set the Roboflow API key for accessing datasets:
|
||||
>>> benchmark = RF100Benchmark()
|
||||
>>> benchmark.set_key("your_roboflow_api_key")
|
||||
"""
|
||||
check_requirements("roboflow")
|
||||
from roboflow import Roboflow
|
||||
|
||||
self.rf = Roboflow(api_key=api_key)
|
||||
|
||||
def parse_dataset(self, ds_link_txt: str = "datasets_links.txt"):
|
||||
"""Parse dataset links and download datasets.
|
||||
|
||||
Args:
|
||||
ds_link_txt (str): Path to the file containing dataset links.
|
||||
|
||||
Returns:
|
||||
(tuple[list[str], list[Path]]): List of dataset names and list of paths to dataset configuration files.
|
||||
|
||||
Examples:
|
||||
>>> benchmark = RF100Benchmark()
|
||||
>>> benchmark.set_key("api_key")
|
||||
>>> benchmark.parse_dataset("datasets_links.txt")
|
||||
"""
|
||||
(shutil.rmtree("rf-100"), os.mkdir("rf-100")) if os.path.exists("rf-100") else os.mkdir("rf-100")
|
||||
os.chdir("rf-100")
|
||||
os.mkdir("ultralytics-benchmarks")
|
||||
safe_download(f"{ASSETS_URL}/datasets_links.txt")
|
||||
|
||||
with open(ds_link_txt, encoding="utf-8") as file:
|
||||
for line in file:
|
||||
try:
|
||||
_, _url, workspace, project, version = re.split("/+", line.strip())
|
||||
self.ds_names.append(project)
|
||||
proj_version = f"{project}-{version}"
|
||||
if not Path(proj_version).exists():
|
||||
self.rf.workspace(workspace).project(project).version(version).download("yolov8")
|
||||
else:
|
||||
LOGGER.info("Dataset already downloaded.")
|
||||
self.ds_cfg_list.append(Path.cwd() / proj_version / "data.yaml")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return self.ds_names, self.ds_cfg_list
|
||||
|
||||
@staticmethod
|
||||
def fix_yaml(path: Path):
|
||||
"""Fix the train and validation paths in a given YAML file."""
|
||||
yaml_data = YAML.load(path)
|
||||
yaml_data["train"] = "train/images"
|
||||
yaml_data["val"] = "valid/images"
|
||||
YAML.dump(yaml_data, path)
|
||||
|
||||
def evaluate(self, yaml_path: str, val_log_file: str, eval_log_file: str, list_ind: int):
|
||||
"""Evaluate model performance on validation results.
|
||||
|
||||
Args:
|
||||
yaml_path (str): Path to the YAML configuration file.
|
||||
val_log_file (str): Path to the validation log file.
|
||||
eval_log_file (str): Path to the evaluation log file.
|
||||
list_ind (int): Index of the current dataset in the list.
|
||||
|
||||
Returns:
|
||||
(float): The mean average precision (mAP) value for the evaluated model.
|
||||
|
||||
Examples:
|
||||
Evaluate a model on a specific dataset
|
||||
>>> benchmark = RF100Benchmark()
|
||||
>>> benchmark.evaluate("path/to/data.yaml", "path/to/val_log.txt", "path/to/eval_log.txt", 0)
|
||||
"""
|
||||
skip_symbols = ["🚀", "⚠️", "💡", "❌"]
|
||||
class_names = YAML.load(yaml_path)["names"]
|
||||
with open(val_log_file, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
eval_lines = []
|
||||
for line in lines:
|
||||
if any(symbol in line for symbol in skip_symbols):
|
||||
continue
|
||||
entries = line.split(" ")
|
||||
entries = list(filter(lambda val: val != "", entries))
|
||||
entries = [e.strip("\n") for e in entries]
|
||||
eval_lines.extend(
|
||||
{
|
||||
"class": entries[0],
|
||||
"images": entries[1],
|
||||
"targets": entries[2],
|
||||
"precision": entries[3],
|
||||
"recall": entries[4],
|
||||
"map50": entries[5],
|
||||
"map95": entries[6],
|
||||
}
|
||||
for e in entries
|
||||
if e in class_names or (e == "all" and "(AP)" not in entries and "(AR)" not in entries)
|
||||
)
|
||||
map_val = 0.0
|
||||
if len(eval_lines) > 1:
|
||||
LOGGER.info("Multiple dicts found")
|
||||
for lst in eval_lines:
|
||||
if lst["class"] == "all":
|
||||
map_val = lst["map50"]
|
||||
else:
|
||||
LOGGER.info("Single dict found")
|
||||
map_val = next(res["map50"] for res in eval_lines)
|
||||
|
||||
with open(eval_log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"{self.ds_names[list_ind]}: {map_val}\n")
|
||||
|
||||
return float(map_val)
|
||||
|
||||
|
||||
class ProfileModels:
|
||||
"""ProfileModels class for profiling different models on ONNX and TensorRT.
|
||||
|
||||
This class profiles the performance of different models, returning results such as model speed and FLOPs.
|
||||
|
||||
Attributes:
|
||||
paths (list[str]): Paths of the models to profile.
|
||||
num_timed_runs (int): Number of timed runs for the profiling.
|
||||
num_warmup_runs (int): Number of warmup runs before profiling.
|
||||
min_time (float): Minimum number of seconds to profile for.
|
||||
imgsz (int): Image size used in the models.
|
||||
half (bool): Flag to indicate whether to use FP16 half-precision for TensorRT profiling.
|
||||
trt (bool): Flag to indicate whether to profile using TensorRT.
|
||||
device (torch.device): Device used for profiling.
|
||||
|
||||
Methods:
|
||||
run: Profile YOLO models for speed and accuracy across various formats.
|
||||
get_files: Get all relevant model files.
|
||||
get_onnx_model_info: Extract metadata from an ONNX model.
|
||||
iterative_sigma_clipping: Apply sigma clipping to remove outliers.
|
||||
profile_tensorrt_model: Profile a TensorRT model.
|
||||
profile_onnx_model: Profile an ONNX model.
|
||||
generate_table_row: Generate a table row with model metrics.
|
||||
generate_results_dict: Generate a dictionary of profiling results.
|
||||
print_table: Print a formatted table of results.
|
||||
|
||||
Examples:
|
||||
Profile models and print results
|
||||
>>> from ultralytics.utils.benchmarks import ProfileModels
|
||||
>>> profiler = ProfileModels(["yolo26n.yaml", "yolov8s.yaml"], imgsz=640)
|
||||
>>> profiler.run()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
paths: list[str],
|
||||
num_timed_runs: int = 100,
|
||||
num_warmup_runs: int = 10,
|
||||
min_time: float = 60,
|
||||
imgsz: int = 640,
|
||||
half: bool = True,
|
||||
trt: bool = True,
|
||||
device: torch.device | str | None = None,
|
||||
):
|
||||
"""Initialize the ProfileModels class for profiling models.
|
||||
|
||||
Args:
|
||||
paths (list[str]): List of paths of the models to be profiled.
|
||||
num_timed_runs (int): Number of timed runs for the profiling.
|
||||
num_warmup_runs (int): Number of warmup runs before the actual profiling starts.
|
||||
min_time (float): Minimum time in seconds for profiling a model.
|
||||
imgsz (int): Size of the image used during profiling.
|
||||
half (bool): Flag to indicate whether to use FP16 half-precision for TensorRT profiling.
|
||||
trt (bool): Flag to indicate whether to profile using TensorRT.
|
||||
device (torch.device | str | None): Device used for profiling. If None, it is determined automatically.
|
||||
|
||||
Notes:
|
||||
FP16 'half' argument option removed for ONNX as slower on CPU than FP32.
|
||||
"""
|
||||
self.paths = paths
|
||||
self.num_timed_runs = num_timed_runs
|
||||
self.num_warmup_runs = num_warmup_runs
|
||||
self.min_time = min_time
|
||||
self.imgsz = imgsz
|
||||
self.half = half
|
||||
self.trt = trt # run TensorRT profiling
|
||||
self.device = device if isinstance(device, torch.device) else select_device(device)
|
||||
|
||||
def run(self):
|
||||
"""Profile YOLO models for speed and accuracy across various formats including ONNX and TensorRT.
|
||||
|
||||
Returns:
|
||||
(list[dict]): List of dictionaries containing profiling results for each model.
|
||||
|
||||
Examples:
|
||||
Profile models and print results
|
||||
>>> from ultralytics.utils.benchmarks import ProfileModels
|
||||
>>> profiler = ProfileModels(["yolo26n.yaml", "yolo11s.yaml"])
|
||||
>>> results = profiler.run()
|
||||
"""
|
||||
files = self.get_files()
|
||||
|
||||
if not files:
|
||||
LOGGER.warning("No matching *.pt or *.onnx files found.")
|
||||
return []
|
||||
|
||||
table_rows = []
|
||||
output = []
|
||||
for file in files:
|
||||
engine_file = file.with_suffix(".engine")
|
||||
if file.suffix in {".pt", ".yaml", ".yml"}:
|
||||
model = YOLO(str(file))
|
||||
model.fuse() # to report correct params and GFLOPs in model.info()
|
||||
model_info = model.info(imgsz=self.imgsz)
|
||||
if self.trt and self.device.type != "cpu" and not engine_file.is_file():
|
||||
engine_file = model.export(
|
||||
format="engine",
|
||||
half=self.half,
|
||||
imgsz=self.imgsz,
|
||||
device=self.device,
|
||||
verbose=False,
|
||||
)
|
||||
onnx_file = model.export(
|
||||
format="onnx",
|
||||
imgsz=self.imgsz,
|
||||
device=self.device,
|
||||
verbose=False,
|
||||
)
|
||||
elif file.suffix == ".onnx":
|
||||
model_info = self.get_onnx_model_info(file)
|
||||
onnx_file = file
|
||||
else:
|
||||
continue
|
||||
|
||||
t_engine = self.profile_tensorrt_model(str(engine_file))
|
||||
t_onnx = self.profile_onnx_model(str(onnx_file))
|
||||
table_rows.append(self.generate_table_row(file.stem, t_onnx, t_engine, model_info))
|
||||
output.append(self.generate_results_dict(file.stem, t_onnx, t_engine, model_info))
|
||||
|
||||
self.print_table(table_rows)
|
||||
return output
|
||||
|
||||
def get_files(self):
|
||||
"""Return a list of paths for all relevant model files given by the user.
|
||||
|
||||
Returns:
|
||||
(list[Path]): List of Path objects for the model files.
|
||||
"""
|
||||
files = []
|
||||
for path in self.paths:
|
||||
path = Path(path)
|
||||
if path.is_dir():
|
||||
extensions = ["*.pt", "*.onnx", "*.yaml"]
|
||||
files.extend([file for ext in extensions for file in glob.glob(str(path / ext))])
|
||||
elif path.suffix in {".pt", ".yaml", ".yml"}: # add non-existing
|
||||
files.append(str(path))
|
||||
else:
|
||||
files.extend(glob.glob(str(path)))
|
||||
|
||||
LOGGER.info(f"Profiling: {sorted(files)}")
|
||||
return [Path(file) for file in sorted(files)]
|
||||
|
||||
@staticmethod
|
||||
def get_onnx_model_info(onnx_file: str):
|
||||
"""Extract metadata from an ONNX model file including layers, parameters, gradients, and FLOPs."""
|
||||
return 0.0, 0.0, 0.0, 0.0 # return (num_layers, num_params, num_gradients, num_flops)
|
||||
|
||||
@staticmethod
|
||||
def iterative_sigma_clipping(data: np.ndarray, sigma: float = 2, max_iters: int = 3):
|
||||
"""Apply iterative sigma clipping to data to remove outliers.
|
||||
|
||||
Args:
|
||||
data (np.ndarray): Input data array.
|
||||
sigma (float): Number of standard deviations to use for clipping.
|
||||
max_iters (int): Maximum number of iterations for the clipping process.
|
||||
|
||||
Returns:
|
||||
(np.ndarray): Clipped data array with outliers removed.
|
||||
"""
|
||||
data = np.array(data)
|
||||
for _ in range(max_iters):
|
||||
mean, std = np.mean(data), np.std(data)
|
||||
clipped_data = data[(data > mean - sigma * std) & (data < mean + sigma * std)]
|
||||
if len(clipped_data) == len(data):
|
||||
break
|
||||
data = clipped_data
|
||||
return data
|
||||
|
||||
def profile_tensorrt_model(self, engine_file: str, eps: float = 1e-3):
|
||||
"""Profile YOLO model performance with TensorRT, measuring average run time and standard deviation.
|
||||
|
||||
Args:
|
||||
engine_file (str): Path to the TensorRT engine file.
|
||||
eps (float): Small epsilon value to prevent division by zero.
|
||||
|
||||
Returns:
|
||||
(tuple[float, float]): Mean and standard deviation of inference time in milliseconds.
|
||||
"""
|
||||
if not self.trt or not Path(engine_file).is_file():
|
||||
return 0.0, 0.0
|
||||
|
||||
# Model and input
|
||||
model = YOLO(engine_file)
|
||||
input_data = np.zeros((self.imgsz, self.imgsz, 3), dtype=np.uint8) # use uint8 for Classify
|
||||
|
||||
# Warmup runs
|
||||
elapsed = 0.0
|
||||
for _ in range(3):
|
||||
start_time = time.time()
|
||||
for _ in range(self.num_warmup_runs):
|
||||
model(input_data, imgsz=self.imgsz, verbose=False)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Compute number of runs as higher of min_time or num_timed_runs
|
||||
num_runs = max(round(self.min_time / (elapsed + eps) * self.num_warmup_runs), self.num_timed_runs * 50)
|
||||
|
||||
# Timed runs
|
||||
run_times = []
|
||||
for _ in TQDM(range(num_runs), desc=engine_file):
|
||||
results = model(input_data, imgsz=self.imgsz, verbose=False)
|
||||
run_times.append(results[0].speed["inference"]) # Convert to milliseconds
|
||||
|
||||
run_times = self.iterative_sigma_clipping(np.array(run_times), sigma=2, max_iters=3) # sigma clipping
|
||||
return np.mean(run_times), np.std(run_times)
|
||||
|
||||
@staticmethod
|
||||
def check_dynamic(tensor_shape):
|
||||
"""Check whether the tensor shape in the ONNX model is dynamic."""
|
||||
return not all(isinstance(dim, int) and dim >= 0 for dim in tensor_shape)
|
||||
|
||||
def profile_onnx_model(self, onnx_file: str, eps: float = 1e-3):
|
||||
"""Profile an ONNX model, measuring average inference time and standard deviation across multiple runs.
|
||||
|
||||
Args:
|
||||
onnx_file (str): Path to the ONNX model file.
|
||||
eps (float): Small epsilon value to prevent division by zero.
|
||||
|
||||
Returns:
|
||||
(tuple[float, float]): Mean and standard deviation of inference time in milliseconds.
|
||||
"""
|
||||
check_requirements([("onnxruntime", "onnxruntime-gpu")]) # either package meets requirements
|
||||
import onnxruntime as ort
|
||||
|
||||
# Session with either 'TensorrtExecutionProvider', 'CUDAExecutionProvider', 'CPUExecutionProvider'
|
||||
sess_options = ort.SessionOptions()
|
||||
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
sess_options.intra_op_num_threads = 8 # Limit the number of threads
|
||||
sess = ort.InferenceSession(onnx_file, sess_options, providers=["CPUExecutionProvider"])
|
||||
|
||||
input_data_dict = {}
|
||||
for input_tensor in sess.get_inputs():
|
||||
input_type = input_tensor.type
|
||||
if self.check_dynamic(input_tensor.shape):
|
||||
if len(input_tensor.shape) != 4 and self.check_dynamic(input_tensor.shape[1:]):
|
||||
raise ValueError(f"Unsupported dynamic shape {input_tensor.shape} of {input_tensor.name}")
|
||||
input_shape = (
|
||||
(1, 3, self.imgsz, self.imgsz) if len(input_tensor.shape) == 4 else (1, *input_tensor.shape[1:])
|
||||
)
|
||||
else:
|
||||
input_shape = input_tensor.shape
|
||||
|
||||
# Mapping ONNX datatype to numpy datatype
|
||||
if "float16" in input_type:
|
||||
input_dtype = np.float16
|
||||
elif "float" in input_type:
|
||||
input_dtype = np.float32
|
||||
elif "double" in input_type:
|
||||
input_dtype = np.float64
|
||||
elif "int64" in input_type:
|
||||
input_dtype = np.int64
|
||||
elif "int32" in input_type:
|
||||
input_dtype = np.int32
|
||||
else:
|
||||
raise ValueError(f"Unsupported ONNX datatype {input_type}")
|
||||
|
||||
input_data = np.random.rand(*input_shape).astype(input_dtype)
|
||||
input_name = input_tensor.name
|
||||
input_data_dict[input_name] = input_data
|
||||
|
||||
output_name = sess.get_outputs()[0].name
|
||||
|
||||
# Warmup runs
|
||||
elapsed = 0.0
|
||||
for _ in range(3):
|
||||
start_time = time.time()
|
||||
for _ in range(self.num_warmup_runs):
|
||||
sess.run([output_name], input_data_dict)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Compute number of runs as higher of min_time or num_timed_runs
|
||||
num_runs = max(round(self.min_time / (elapsed + eps) * self.num_warmup_runs), self.num_timed_runs)
|
||||
|
||||
# Timed runs
|
||||
run_times = []
|
||||
for _ in TQDM(range(num_runs), desc=onnx_file):
|
||||
start_time = time.time()
|
||||
sess.run([output_name], input_data_dict)
|
||||
run_times.append((time.time() - start_time) * 1000) # Convert to milliseconds
|
||||
|
||||
run_times = self.iterative_sigma_clipping(np.array(run_times), sigma=2, max_iters=5) # sigma clipping
|
||||
return np.mean(run_times), np.std(run_times)
|
||||
|
||||
def generate_table_row(
|
||||
self,
|
||||
model_name: str,
|
||||
t_onnx: tuple[float, float],
|
||||
t_engine: tuple[float, float],
|
||||
model_info: tuple[float, float, float, float],
|
||||
):
|
||||
"""Generate a table row string with model performance metrics.
|
||||
|
||||
Args:
|
||||
model_name (str): Name of the model.
|
||||
t_onnx (tuple): ONNX model inference time statistics (mean, std).
|
||||
t_engine (tuple): TensorRT engine inference time statistics (mean, std).
|
||||
model_info (tuple): Model information (layers, params, gradients, flops).
|
||||
|
||||
Returns:
|
||||
(str): Formatted table row string with model metrics.
|
||||
"""
|
||||
_layers, params, _gradients, flops = model_info
|
||||
return (
|
||||
f"| {model_name:18s} | {self.imgsz} | - | {t_onnx[0]:.1f}±{t_onnx[1]:.1f} ms | {t_engine[0]:.1f}±"
|
||||
f"{t_engine[1]:.1f} ms | {params / 1e6:.1f} | {flops:.1f} |"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_results_dict(
|
||||
model_name: str,
|
||||
t_onnx: tuple[float, float],
|
||||
t_engine: tuple[float, float],
|
||||
model_info: tuple[float, float, float, float],
|
||||
):
|
||||
"""Generate a dictionary of profiling results.
|
||||
|
||||
Args:
|
||||
model_name (str): Name of the model.
|
||||
t_onnx (tuple): ONNX model inference time statistics (mean, std).
|
||||
t_engine (tuple): TensorRT engine inference time statistics (mean, std).
|
||||
model_info (tuple): Model information (layers, params, gradients, flops).
|
||||
|
||||
Returns:
|
||||
(dict): Dictionary containing profiling results.
|
||||
"""
|
||||
_layers, params, _gradients, flops = model_info
|
||||
return {
|
||||
"model/name": model_name,
|
||||
"model/parameters": params,
|
||||
"model/GFLOPs": round(flops, 3),
|
||||
"model/speed_ONNX(ms)": round(t_onnx[0], 3),
|
||||
"model/speed_TensorRT(ms)": round(t_engine[0], 3),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def print_table(table_rows: list[str]):
|
||||
"""Print a formatted table of model profiling results.
|
||||
|
||||
Args:
|
||||
table_rows (list[str]): List of formatted table row strings.
|
||||
"""
|
||||
gpu = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "GPU"
|
||||
headers = [
|
||||
"Model",
|
||||
"size<br><sup>(pixels)",
|
||||
"mAP<sup>val<br>50-95",
|
||||
f"Speed<br><sup>CPU ({get_cpu_info()}) ONNX<br>(ms)",
|
||||
f"Speed<br><sup>{gpu} TensorRT<br>(ms)",
|
||||
"params<br><sup>(M)",
|
||||
"FLOPs<br><sup>(B)",
|
||||
]
|
||||
header = "|" + "|".join(f" {h} " for h in headers) + "|"
|
||||
separator = "|" + "|".join("-" * (len(h) + 2) for h in headers) + "|"
|
||||
|
||||
LOGGER.info(f"\n\n{header}")
|
||||
LOGGER.info(separator)
|
||||
for row in table_rows:
|
||||
LOGGER.info(row)
|
||||
5
ultralytics/utils/callbacks/__init__.py
Executable file
5
ultralytics/utils/callbacks/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from .base import add_integration_callbacks, default_callbacks, get_default_callbacks
|
||||
|
||||
__all__ = "add_integration_callbacks", "default_callbacks", "get_default_callbacks"
|
||||
233
ultralytics/utils/callbacks/base.py
Executable file
233
ultralytics/utils/callbacks/base.py
Executable file
@@ -0,0 +1,233 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
"""Base callbacks for Ultralytics training, validation, prediction, and export processes."""
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
|
||||
# Trainer callbacks ----------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer):
|
||||
"""Called before the pretraining routine starts."""
|
||||
pass
|
||||
|
||||
|
||||
def on_pretrain_routine_end(trainer):
|
||||
"""Called after the pretraining routine ends."""
|
||||
pass
|
||||
|
||||
|
||||
def on_train_start(trainer):
|
||||
"""Called when the training starts."""
|
||||
pass
|
||||
|
||||
|
||||
def on_train_epoch_start(trainer):
|
||||
"""Called at the start of each training epoch."""
|
||||
pass
|
||||
|
||||
|
||||
def on_train_batch_start(trainer):
|
||||
"""Called at the start of each training batch."""
|
||||
pass
|
||||
|
||||
|
||||
def optimizer_step(trainer):
|
||||
"""Called when the optimizer takes a step."""
|
||||
pass
|
||||
|
||||
|
||||
def on_before_zero_grad(trainer):
|
||||
"""Called before the gradients are set to zero."""
|
||||
pass
|
||||
|
||||
|
||||
def on_train_batch_end(trainer):
|
||||
"""Called at the end of each training batch."""
|
||||
pass
|
||||
|
||||
|
||||
def on_train_epoch_end(trainer):
|
||||
"""Called at the end of each training epoch."""
|
||||
pass
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
"""Called at the end of each fit epoch (train + val)."""
|
||||
pass
|
||||
|
||||
|
||||
def on_model_save(trainer):
|
||||
"""Called when the model is saved."""
|
||||
pass
|
||||
|
||||
|
||||
def on_train_end(trainer):
|
||||
"""Called when the training ends."""
|
||||
pass
|
||||
|
||||
|
||||
def on_params_update(trainer):
|
||||
"""Called when the model parameters are updated."""
|
||||
pass
|
||||
|
||||
|
||||
def teardown(trainer):
|
||||
"""Called during the teardown of the training process."""
|
||||
pass
|
||||
|
||||
|
||||
# Validator callbacks --------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def on_val_start(validator):
|
||||
"""Called when the validation starts."""
|
||||
pass
|
||||
|
||||
|
||||
def on_val_batch_start(validator):
|
||||
"""Called at the start of each validation batch."""
|
||||
pass
|
||||
|
||||
|
||||
def on_val_batch_end(validator):
|
||||
"""Called at the end of each validation batch."""
|
||||
pass
|
||||
|
||||
|
||||
def on_val_end(validator):
|
||||
"""Called when the validation ends."""
|
||||
pass
|
||||
|
||||
|
||||
# Predictor callbacks --------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def on_predict_start(predictor):
|
||||
"""Called when the prediction starts."""
|
||||
pass
|
||||
|
||||
|
||||
def on_predict_batch_start(predictor):
|
||||
"""Called at the start of each prediction batch."""
|
||||
pass
|
||||
|
||||
|
||||
def on_predict_batch_end(predictor):
|
||||
"""Called at the end of each prediction batch."""
|
||||
pass
|
||||
|
||||
|
||||
def on_predict_postprocess_end(predictor):
|
||||
"""Called after the post-processing of the prediction ends."""
|
||||
pass
|
||||
|
||||
|
||||
def on_predict_end(predictor):
|
||||
"""Called when the prediction ends."""
|
||||
pass
|
||||
|
||||
|
||||
# Exporter callbacks ---------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def on_export_start(exporter):
|
||||
"""Called when the model export starts."""
|
||||
pass
|
||||
|
||||
|
||||
def on_export_end(exporter):
|
||||
"""Called when the model export ends."""
|
||||
pass
|
||||
|
||||
|
||||
default_callbacks = {
|
||||
# Run in trainer
|
||||
"on_pretrain_routine_start": [on_pretrain_routine_start],
|
||||
"on_pretrain_routine_end": [on_pretrain_routine_end],
|
||||
"on_train_start": [on_train_start],
|
||||
"on_train_epoch_start": [on_train_epoch_start],
|
||||
"on_train_batch_start": [on_train_batch_start],
|
||||
"optimizer_step": [optimizer_step],
|
||||
"on_before_zero_grad": [on_before_zero_grad],
|
||||
"on_train_batch_end": [on_train_batch_end],
|
||||
"on_train_epoch_end": [on_train_epoch_end],
|
||||
"on_fit_epoch_end": [on_fit_epoch_end], # fit = train + val
|
||||
"on_model_save": [on_model_save],
|
||||
"on_train_end": [on_train_end],
|
||||
"on_params_update": [on_params_update],
|
||||
"teardown": [teardown],
|
||||
# Run in validator
|
||||
"on_val_start": [on_val_start],
|
||||
"on_val_batch_start": [on_val_batch_start],
|
||||
"on_val_batch_end": [on_val_batch_end],
|
||||
"on_val_end": [on_val_end],
|
||||
# Run in predictor
|
||||
"on_predict_start": [on_predict_start],
|
||||
"on_predict_batch_start": [on_predict_batch_start],
|
||||
"on_predict_postprocess_end": [on_predict_postprocess_end],
|
||||
"on_predict_batch_end": [on_predict_batch_end],
|
||||
"on_predict_end": [on_predict_end],
|
||||
# Run in exporter
|
||||
"on_export_start": [on_export_start],
|
||||
"on_export_end": [on_export_end],
|
||||
}
|
||||
|
||||
|
||||
def get_default_callbacks():
|
||||
"""Get the default callbacks for Ultralytics training, validation, prediction, and export processes.
|
||||
|
||||
Returns:
|
||||
(dict): Dictionary of default callbacks for various training events. Each key represents an event during the
|
||||
training process, and the corresponding value is a list of callback functions executed when that
|
||||
event occurs.
|
||||
|
||||
Examples:
|
||||
>>> callbacks = get_default_callbacks()
|
||||
>>> print(list(callbacks.keys())) # show all available callback events
|
||||
['on_pretrain_routine_start', 'on_pretrain_routine_end', ...]
|
||||
"""
|
||||
return defaultdict(list, deepcopy(default_callbacks))
|
||||
|
||||
|
||||
def add_integration_callbacks(instance):
|
||||
"""Add integration callbacks to the instance's callbacks dictionary.
|
||||
|
||||
This function loads and adds various integration callbacks to the provided instance. The specific callbacks added
|
||||
depend on the type of instance provided. All instances receive HUB callbacks, while Trainer instances also receive
|
||||
additional callbacks for various integrations like ClearML, Comet, DVC, MLflow, Neptune, Ray Tune, TensorBoard, and
|
||||
Weights & Biases.
|
||||
|
||||
Args:
|
||||
instance (Trainer | Predictor | Validator | Exporter): The object instance to which callbacks will be added. The
|
||||
type of instance determines which callbacks are loaded.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.engine.trainer import BaseTrainer
|
||||
>>> trainer = BaseTrainer()
|
||||
>>> add_integration_callbacks(trainer)
|
||||
"""
|
||||
from .hub import callbacks as hub_cb
|
||||
from .platform import callbacks as platform_cb
|
||||
|
||||
# Load Ultralytics callbacks
|
||||
callbacks_list = [hub_cb, platform_cb]
|
||||
|
||||
# Load training callbacks
|
||||
if "Trainer" in instance.__class__.__name__:
|
||||
from .clearml import callbacks as clear_cb
|
||||
from .comet import callbacks as comet_cb
|
||||
from .dvc import callbacks as dvc_cb
|
||||
from .mlflow import callbacks as mlflow_cb
|
||||
from .neptune import callbacks as neptune_cb
|
||||
from .raytune import callbacks as tune_cb
|
||||
from .tensorboard import callbacks as tb_cb
|
||||
from .wb import callbacks as wb_cb
|
||||
|
||||
callbacks_list.extend([clear_cb, comet_cb, dvc_cb, mlflow_cb, neptune_cb, tune_cb, tb_cb, wb_cb])
|
||||
|
||||
# Add the callbacks to the callbacks dictionary
|
||||
for callbacks in callbacks_list:
|
||||
for k, v in callbacks.items():
|
||||
if v not in instance.callbacks[k]:
|
||||
instance.callbacks[k].append(v)
|
||||
146
ultralytics/utils/callbacks/clearml.py
Executable file
146
ultralytics/utils/callbacks/clearml.py
Executable file
@@ -0,0 +1,146 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from ultralytics.utils import LOGGER, SETTINGS, TESTS_RUNNING
|
||||
|
||||
try:
|
||||
assert not TESTS_RUNNING # do not log pytest
|
||||
assert SETTINGS["clearml"] is True # verify integration is enabled
|
||||
import clearml
|
||||
from clearml import Task
|
||||
|
||||
assert hasattr(clearml, "__version__") # verify package is not directory
|
||||
|
||||
except (ImportError, AssertionError):
|
||||
clearml = None
|
||||
|
||||
|
||||
def _log_debug_samples(files, title: str = "Debug Samples") -> None:
|
||||
"""Log files (images) as debug samples in the ClearML task.
|
||||
|
||||
Args:
|
||||
files (list[Path]): A list of file paths in PosixPath format.
|
||||
title (str): A title that groups together images with the same values.
|
||||
"""
|
||||
import re
|
||||
|
||||
if task := Task.current_task():
|
||||
for f in files:
|
||||
if f.exists():
|
||||
it = re.search(r"_batch(\d+)", f.name)
|
||||
iteration = int(it.groups()[0]) if it else 0
|
||||
task.get_logger().report_image(
|
||||
title=title, series=f.name.replace(it.group(), ""), local_path=str(f), iteration=iteration
|
||||
)
|
||||
|
||||
|
||||
def _log_plot(title: str, plot_path: str) -> None:
|
||||
"""Log an image as a plot in the plot section of ClearML.
|
||||
|
||||
Args:
|
||||
title (str): The title of the plot.
|
||||
plot_path (str | Path): The path to the saved image file.
|
||||
"""
|
||||
import matplotlib.image as mpimg
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
img = mpimg.imread(plot_path)
|
||||
fig = plt.figure()
|
||||
ax = fig.add_axes([0, 0, 1, 1], frameon=False, aspect="auto", xticks=[], yticks=[]) # no ticks
|
||||
ax.imshow(img)
|
||||
|
||||
Task.current_task().get_logger().report_matplotlib_figure(
|
||||
title=title, series="", figure=fig, report_interactive=False
|
||||
)
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer) -> None:
|
||||
"""Initialize and connect ClearML task at the start of pretraining routine."""
|
||||
try:
|
||||
if task := Task.current_task():
|
||||
# WARNING: make sure the automatic pytorch and matplotlib bindings are disabled!
|
||||
# We are logging these plots and model files manually in the integration
|
||||
from clearml.binding.frameworks.pytorch_bind import PatchPyTorchModelIO
|
||||
from clearml.binding.matplotlib_bind import PatchedMatplotlib
|
||||
|
||||
PatchPyTorchModelIO.update_current_task(None)
|
||||
PatchedMatplotlib.update_current_task(None)
|
||||
else:
|
||||
task = Task.init(
|
||||
project_name=trainer.args.project or "Ultralytics",
|
||||
task_name=trainer.args.name,
|
||||
tags=["Ultralytics"],
|
||||
output_uri=True,
|
||||
reuse_last_task_id=False,
|
||||
auto_connect_frameworks={"pytorch": False, "matplotlib": False},
|
||||
)
|
||||
LOGGER.warning(
|
||||
"ClearML Initialized a new task. If you want to run remotely, "
|
||||
"please add clearml-init and connect your arguments before initializing YOLO."
|
||||
)
|
||||
task.connect(vars(trainer.args), name="General")
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"ClearML installed but not initialized correctly, not logging this run. {e}")
|
||||
|
||||
|
||||
def on_train_epoch_end(trainer) -> None:
|
||||
"""Log debug samples for the first epoch and report current training progress."""
|
||||
if task := Task.current_task():
|
||||
# Log debug samples for first epoch only
|
||||
if trainer.epoch == 1:
|
||||
_log_debug_samples(sorted(trainer.save_dir.glob("train_batch*.jpg")), "Mosaic")
|
||||
# Report the current training progress
|
||||
for k, v in trainer.label_loss_items(trainer.tloss, prefix="train").items():
|
||||
task.get_logger().report_scalar("train", k, v, iteration=trainer.epoch)
|
||||
for k, v in trainer.lr.items():
|
||||
task.get_logger().report_scalar("lr", k, v, iteration=trainer.epoch)
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer) -> None:
|
||||
"""Report model information and metrics to logger at the end of an epoch."""
|
||||
if task := Task.current_task():
|
||||
# Report epoch time and validation metrics
|
||||
task.get_logger().report_scalar(
|
||||
title="Epoch Time", series="Epoch Time", value=trainer.epoch_time, iteration=trainer.epoch
|
||||
)
|
||||
for k, v in trainer.metrics.items():
|
||||
title = k.split("/")[0]
|
||||
task.get_logger().report_scalar(title, k, v, iteration=trainer.epoch)
|
||||
if trainer.epoch == 0:
|
||||
from ultralytics.utils.torch_utils import model_info_for_loggers
|
||||
|
||||
for k, v in model_info_for_loggers(trainer).items():
|
||||
task.get_logger().report_single_value(k, v)
|
||||
|
||||
|
||||
def on_val_end(validator) -> None:
|
||||
"""Log validation results including labels and predictions."""
|
||||
if Task.current_task():
|
||||
# Log validation labels and predictions
|
||||
_log_debug_samples(sorted(validator.save_dir.glob("val*.jpg")), "Validation")
|
||||
|
||||
|
||||
def on_train_end(trainer) -> None:
|
||||
"""Log final model and training results on training completion."""
|
||||
if task := Task.current_task():
|
||||
# Log final results, confusion matrix and PR plots
|
||||
for f in [*trainer.plots.keys(), *trainer.validator.plots.keys()]:
|
||||
if "batch" not in f.name:
|
||||
_log_plot(title=f.stem, plot_path=f)
|
||||
# Report final metrics
|
||||
for k, v in trainer.validator.metrics.results_dict.items():
|
||||
task.get_logger().report_single_value(k, v)
|
||||
# Log the final model
|
||||
task.update_output_model(model_path=str(trainer.best), model_name=trainer.args.name, auto_delete_file=False)
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_train_epoch_end": on_train_epoch_end,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_val_end": on_val_end,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if clearml
|
||||
else {}
|
||||
)
|
||||
622
ultralytics/utils/callbacks/comet.py
Executable file
622
ultralytics/utils/callbacks/comet.py
Executable file
@@ -0,0 +1,622 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from ultralytics.utils import LOGGER, RANK, SETTINGS, TESTS_RUNNING, ops
|
||||
from ultralytics.utils.metrics import ClassifyMetrics, DetMetrics, OBBMetrics, PoseMetrics, SegmentMetrics
|
||||
|
||||
try:
|
||||
assert not TESTS_RUNNING # do not log pytest
|
||||
assert SETTINGS["comet"] is True # verify integration is enabled
|
||||
import comet_ml
|
||||
|
||||
assert hasattr(comet_ml, "__version__") # verify package is not directory
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Ensures certain logging functions only run for supported tasks
|
||||
COMET_SUPPORTED_TASKS = ["detect", "segment"]
|
||||
|
||||
# Names of plots created by Ultralytics that are logged to Comet
|
||||
CONFUSION_MATRIX_PLOT_NAMES = "confusion_matrix", "confusion_matrix_normalized"
|
||||
EVALUATION_PLOT_NAMES = "F1_curve", "P_curve", "R_curve", "PR_curve"
|
||||
LABEL_PLOT_NAMES = ["labels"]
|
||||
SEGMENT_METRICS_PLOT_PREFIX = "Box", "Mask"
|
||||
POSE_METRICS_PLOT_PREFIX = "Box", "Pose"
|
||||
DETECTION_METRICS_PLOT_PREFIX = ["Box"]
|
||||
RESULTS_TABLE_NAME = "results.csv"
|
||||
ARGS_YAML_NAME = "args.yaml"
|
||||
|
||||
_comet_image_prediction_count = 0
|
||||
|
||||
except (ImportError, AssertionError):
|
||||
comet_ml = None
|
||||
|
||||
|
||||
def _get_comet_mode() -> str:
|
||||
"""Return the Comet mode from environment variables, defaulting to 'online'."""
|
||||
comet_mode = os.getenv("COMET_MODE")
|
||||
if comet_mode is not None:
|
||||
LOGGER.warning(
|
||||
"The COMET_MODE environment variable is deprecated. "
|
||||
"Please use COMET_START_ONLINE to set the Comet experiment mode. "
|
||||
"To start an offline Comet experiment, use 'export COMET_START_ONLINE=0'. "
|
||||
"If COMET_START_ONLINE is not set or is set to '1', an online Comet experiment will be created."
|
||||
)
|
||||
return comet_mode
|
||||
|
||||
return "online"
|
||||
|
||||
|
||||
def _get_comet_model_name() -> str:
|
||||
"""Return the Comet model name from environment variable or default to 'Ultralytics'."""
|
||||
return os.getenv("COMET_MODEL_NAME", "Ultralytics")
|
||||
|
||||
|
||||
def _get_eval_batch_logging_interval() -> int:
|
||||
"""Get the evaluation batch logging interval from environment variable or use default value 1."""
|
||||
return int(os.getenv("COMET_EVAL_BATCH_LOGGING_INTERVAL", 1))
|
||||
|
||||
|
||||
def _get_max_image_predictions_to_log() -> int:
|
||||
"""Get the maximum number of image predictions to log from environment variables."""
|
||||
return int(os.getenv("COMET_MAX_IMAGE_PREDICTIONS", 100))
|
||||
|
||||
|
||||
def _scale_confidence_score(score: float) -> float:
|
||||
"""Scale the confidence score by a factor specified in environment variable."""
|
||||
scale = float(os.getenv("COMET_MAX_CONFIDENCE_SCORE", 100.0))
|
||||
return score * scale
|
||||
|
||||
|
||||
def _should_log_confusion_matrix() -> bool:
|
||||
"""Determine if the confusion matrix should be logged based on environment variable settings."""
|
||||
return os.getenv("COMET_EVAL_LOG_CONFUSION_MATRIX", "false").lower() == "true"
|
||||
|
||||
|
||||
def _should_log_image_predictions() -> bool:
|
||||
"""Determine whether to log image predictions based on environment variable."""
|
||||
return os.getenv("COMET_EVAL_LOG_IMAGE_PREDICTIONS", "true").lower() == "true"
|
||||
|
||||
|
||||
def _resume_or_create_experiment(args: SimpleNamespace) -> None:
|
||||
"""Resume CometML experiment or create a new experiment based on args.
|
||||
|
||||
Ensures that the experiment object is only created in a single process during distributed training.
|
||||
|
||||
Args:
|
||||
args (SimpleNamespace): Training arguments containing project configuration and other parameters.
|
||||
"""
|
||||
if RANK not in {-1, 0}:
|
||||
return
|
||||
|
||||
# Set environment variable (if not set by the user) to configure the Comet experiment's online mode under the hood.
|
||||
# IF COMET_START_ONLINE is set by the user it will override COMET_MODE value.
|
||||
if os.getenv("COMET_START_ONLINE") is None:
|
||||
comet_mode = _get_comet_mode()
|
||||
os.environ["COMET_START_ONLINE"] = "1" if comet_mode != "offline" else "0"
|
||||
|
||||
try:
|
||||
_project_name = os.getenv("COMET_PROJECT_NAME", args.project)
|
||||
experiment = comet_ml.start(project_name=_project_name)
|
||||
experiment.log_parameters(vars(args))
|
||||
experiment.log_others(
|
||||
{
|
||||
"eval_batch_logging_interval": _get_eval_batch_logging_interval(),
|
||||
"log_confusion_matrix_on_eval": _should_log_confusion_matrix(),
|
||||
"log_image_predictions": _should_log_image_predictions(),
|
||||
"max_image_predictions": _get_max_image_predictions_to_log(),
|
||||
}
|
||||
)
|
||||
experiment.log_other("Created from", "ultralytics")
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Comet installed but not initialized correctly, not logging this run. {e}")
|
||||
|
||||
|
||||
def _fetch_trainer_metadata(trainer) -> dict:
|
||||
"""Return metadata for YOLO training including epoch and asset saving status.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The YOLO trainer object containing training state and config.
|
||||
|
||||
Returns:
|
||||
(dict): Dictionary containing current epoch, step, save assets flag, and final epoch flag.
|
||||
"""
|
||||
curr_epoch = trainer.epoch + 1
|
||||
|
||||
train_num_steps_per_epoch = len(trainer.train_loader.dataset) // trainer.batch_size
|
||||
curr_step = curr_epoch * train_num_steps_per_epoch
|
||||
final_epoch = curr_epoch == trainer.epochs
|
||||
|
||||
save = trainer.args.save
|
||||
save_period = trainer.args.save_period
|
||||
save_interval = curr_epoch % save_period == 0
|
||||
save_assets = save and save_period > 0 and save_interval and not final_epoch
|
||||
|
||||
return dict(curr_epoch=curr_epoch, curr_step=curr_step, save_assets=save_assets, final_epoch=final_epoch)
|
||||
|
||||
|
||||
def _scale_bounding_box_to_original_image_shape(
|
||||
box, resized_image_shape, original_image_shape, ratio_pad
|
||||
) -> list[float]:
|
||||
"""Scale bounding box from resized image coordinates to original image coordinates.
|
||||
|
||||
YOLO resizes images during training and the label values are normalized based on this resized shape. This function
|
||||
rescales the bounding box labels to the original image shape.
|
||||
|
||||
Args:
|
||||
box (torch.Tensor): Bounding box in normalized xywh format.
|
||||
resized_image_shape (tuple): Shape of the resized image (height, width).
|
||||
original_image_shape (tuple): Shape of the original image (height, width).
|
||||
ratio_pad (tuple): Ratio and padding information for scaling.
|
||||
|
||||
Returns:
|
||||
(list[float]): Scaled bounding box coordinates in xywh format with top-left corner adjustment.
|
||||
"""
|
||||
resized_image_height, resized_image_width = resized_image_shape
|
||||
|
||||
# Convert normalized xywh format predictions to xyxy in resized scale format
|
||||
box = ops.xywhn2xyxy(box, h=resized_image_height, w=resized_image_width)
|
||||
# Scale box predictions from resized image scale back to original image scale
|
||||
box = ops.scale_boxes(resized_image_shape, box, original_image_shape, ratio_pad)
|
||||
# Convert bounding box format from xyxy to xywh for Comet logging
|
||||
box = ops.xyxy2xywh(box)
|
||||
# Adjust xy center to correspond top-left corner
|
||||
box[:2] -= box[2:] / 2
|
||||
box = box.tolist()
|
||||
|
||||
return box
|
||||
|
||||
|
||||
def _format_ground_truth_annotations_for_detection(img_idx, image_path, batch, class_name_map=None) -> dict | None:
|
||||
"""Format ground truth annotations for object detection.
|
||||
|
||||
This function processes ground truth annotations from a batch of images for object detection tasks. It extracts
|
||||
bounding boxes, class labels, and other metadata for a specific image in the batch, and formats them for
|
||||
visualization or evaluation.
|
||||
|
||||
Args:
|
||||
img_idx (int): Index of the image in the batch to process.
|
||||
image_path (str | Path): Path to the image file.
|
||||
batch (dict): Batch dictionary containing detection data with keys:
|
||||
- 'batch_idx': Tensor of batch indices
|
||||
- 'bboxes': Tensor of bounding boxes in normalized xywh format
|
||||
- 'cls': Tensor of class labels
|
||||
- 'ori_shape': Original image shapes
|
||||
- 'resized_shape': Resized image shapes
|
||||
- 'ratio_pad': Ratio and padding information
|
||||
class_name_map (dict, optional): Mapping from class indices to class names.
|
||||
|
||||
Returns:
|
||||
(dict | None): Formatted ground truth annotations with keys 'name' and 'data', where 'data' is a list of
|
||||
annotation dicts each containing 'boxes', 'label', and 'score' keys. Returns None if no bounding boxes are
|
||||
found for the image.
|
||||
"""
|
||||
indices = batch["batch_idx"] == img_idx
|
||||
bboxes = batch["bboxes"][indices]
|
||||
if len(bboxes) == 0:
|
||||
LOGGER.debug(f"Comet Image: {image_path} has no bounding boxes labels")
|
||||
return None
|
||||
|
||||
cls_labels = batch["cls"][indices].squeeze(1).tolist()
|
||||
if class_name_map:
|
||||
cls_labels = [str(class_name_map[label]) for label in cls_labels]
|
||||
|
||||
original_image_shape = batch["ori_shape"][img_idx]
|
||||
resized_image_shape = batch["resized_shape"][img_idx]
|
||||
ratio_pad = batch["ratio_pad"][img_idx]
|
||||
|
||||
data = []
|
||||
for box, label in zip(bboxes, cls_labels):
|
||||
box = _scale_bounding_box_to_original_image_shape(box, resized_image_shape, original_image_shape, ratio_pad)
|
||||
data.append(
|
||||
{
|
||||
"boxes": [box],
|
||||
"label": f"gt_{label}",
|
||||
"score": _scale_confidence_score(1.0),
|
||||
}
|
||||
)
|
||||
|
||||
return {"name": "ground_truth", "data": data}
|
||||
|
||||
|
||||
def _format_prediction_annotations(image_path, metadata, class_label_map=None, class_map=None) -> dict | None:
|
||||
"""Format YOLO predictions for object detection visualization.
|
||||
|
||||
Args:
|
||||
image_path (Path): Path to the image file.
|
||||
metadata (dict): Prediction metadata containing bounding boxes and class information.
|
||||
class_label_map (dict, optional): Mapping from class indices to class names.
|
||||
class_map (dict, optional): Additional class mapping for label conversion.
|
||||
|
||||
Returns:
|
||||
(dict | None): Formatted prediction annotations or None if no predictions exist.
|
||||
"""
|
||||
stem = image_path.stem
|
||||
image_id = int(stem) if stem.isnumeric() else stem
|
||||
|
||||
predictions = metadata.get(image_id)
|
||||
if not predictions:
|
||||
LOGGER.debug(f"Comet Image: {image_path} has no bounding boxes predictions")
|
||||
return None
|
||||
|
||||
# apply the mapping that was used to map the predicted classes when the JSON was created
|
||||
if class_label_map and class_map:
|
||||
class_label_map = {class_map[k]: v for k, v in class_label_map.items()}
|
||||
try:
|
||||
# import pycotools utilities to decompress annotations for various tasks, e.g. segmentation
|
||||
from faster_coco_eval.core.mask import decode
|
||||
except ImportError:
|
||||
decode = None
|
||||
|
||||
data = []
|
||||
for prediction in predictions:
|
||||
boxes = prediction["bbox"]
|
||||
score = _scale_confidence_score(prediction["score"])
|
||||
cls_label = prediction["category_id"]
|
||||
if class_label_map:
|
||||
cls_label = str(class_label_map[cls_label])
|
||||
|
||||
annotation_data = {"boxes": [boxes], "label": cls_label, "score": score}
|
||||
|
||||
if decode is not None:
|
||||
# do segmentation processing only if we are able to decode it
|
||||
segments = prediction.get("segmentation", None)
|
||||
if segments is not None:
|
||||
segments = _extract_segmentation_annotation(segments, decode)
|
||||
if segments is not None:
|
||||
annotation_data["points"] = segments
|
||||
|
||||
data.append(annotation_data)
|
||||
|
||||
return {"name": "prediction", "data": data}
|
||||
|
||||
|
||||
def _extract_segmentation_annotation(segmentation_raw: str, decode: Callable) -> list[list[Any]] | None:
|
||||
"""Extract segmentation annotation from compressed segmentations as list of polygons.
|
||||
|
||||
Args:
|
||||
segmentation_raw (str): Raw segmentation data in compressed format.
|
||||
decode (Callable): Function to decode the compressed segmentation data.
|
||||
|
||||
Returns:
|
||||
(list[list[Any]] | None): List of polygon points or None if extraction fails.
|
||||
"""
|
||||
try:
|
||||
mask = decode(segmentation_raw)
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
||||
annotations = [np.array(polygon).squeeze() for polygon in contours if len(polygon) >= 3]
|
||||
return [annotation.ravel().tolist() for annotation in annotations]
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Comet Failed to extract segmentation annotation: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_annotations(img_idx, image_path, batch, prediction_metadata_map, class_label_map, class_map) -> list | None:
|
||||
"""Join the ground truth and prediction annotations if they exist.
|
||||
|
||||
Args:
|
||||
img_idx (int): Index of the image in the batch.
|
||||
image_path (Path): Path to the image file.
|
||||
batch (dict): Batch data containing ground truth annotations.
|
||||
prediction_metadata_map (dict): Map of prediction metadata by image ID.
|
||||
class_label_map (dict): Mapping from class indices to class names.
|
||||
class_map (dict): Additional class mapping for label conversion.
|
||||
|
||||
Returns:
|
||||
(list | None): List of annotation dictionaries or None if no annotations exist.
|
||||
"""
|
||||
ground_truth_annotations = _format_ground_truth_annotations_for_detection(
|
||||
img_idx, image_path, batch, class_label_map
|
||||
)
|
||||
prediction_annotations = _format_prediction_annotations(
|
||||
image_path, prediction_metadata_map, class_label_map, class_map
|
||||
)
|
||||
|
||||
annotations = [
|
||||
annotation for annotation in [ground_truth_annotations, prediction_annotations] if annotation is not None
|
||||
]
|
||||
return [annotations] if annotations else None
|
||||
|
||||
|
||||
def _create_prediction_metadata_map(model_predictions) -> dict:
|
||||
"""Create metadata map for model predictions by grouping them based on image ID."""
|
||||
pred_metadata_map = {}
|
||||
for prediction in model_predictions:
|
||||
pred_metadata_map.setdefault(prediction["image_id"], [])
|
||||
pred_metadata_map[prediction["image_id"]].append(prediction)
|
||||
|
||||
return pred_metadata_map
|
||||
|
||||
|
||||
def _log_confusion_matrix(experiment, trainer, curr_step, curr_epoch) -> None:
|
||||
"""Log the confusion matrix to Comet experiment."""
|
||||
conf_mat = trainer.validator.confusion_matrix.matrix
|
||||
names = [*list(trainer.data["names"].values()), "background"]
|
||||
experiment.log_confusion_matrix(
|
||||
matrix=conf_mat, labels=names, max_categories=len(names), epoch=curr_epoch, step=curr_step
|
||||
)
|
||||
|
||||
|
||||
def _log_images(experiment, image_paths, curr_step: int | None, annotations=None) -> None:
|
||||
"""Log images to the experiment with optional annotations.
|
||||
|
||||
This function logs images to a Comet ML experiment, optionally including annotation data for visualization such as
|
||||
bounding boxes or segmentation masks.
|
||||
|
||||
Args:
|
||||
experiment (comet_ml.CometExperiment): The Comet ML experiment to log images to.
|
||||
image_paths (list[Path]): List of paths to images that will be logged.
|
||||
curr_step (int | None): Current training step/iteration for tracking in the experiment timeline.
|
||||
annotations (list[list[dict]], optional): Nested list of annotation dictionaries for each image. Each annotation
|
||||
contains visualization data like bounding boxes, labels, and confidence scores.
|
||||
"""
|
||||
if annotations:
|
||||
for image_path, annotation in zip(image_paths, annotations):
|
||||
experiment.log_image(image_path, name=image_path.stem, step=curr_step, annotations=annotation)
|
||||
|
||||
else:
|
||||
for image_path in image_paths:
|
||||
experiment.log_image(image_path, name=image_path.stem, step=curr_step)
|
||||
|
||||
|
||||
def _log_image_predictions(experiment, validator, curr_step) -> None:
|
||||
"""Log image predictions to a Comet ML experiment during model validation.
|
||||
|
||||
This function processes validation data and formats both ground truth and prediction annotations for visualization
|
||||
in the Comet dashboard. The function respects configured limits on the number of images to log.
|
||||
|
||||
Args:
|
||||
experiment (comet_ml.CometExperiment): The Comet ML experiment to log to.
|
||||
validator (BaseValidator): The validator instance containing validation data and predictions.
|
||||
curr_step (int): The current training step for logging timeline.
|
||||
|
||||
Notes:
|
||||
This function uses global state to track the number of logged predictions across calls.
|
||||
It only logs predictions for supported tasks defined in COMET_SUPPORTED_TASKS.
|
||||
The number of logged images is limited by the COMET_MAX_IMAGE_PREDICTIONS environment variable.
|
||||
"""
|
||||
global _comet_image_prediction_count
|
||||
|
||||
task = validator.args.task
|
||||
if task not in COMET_SUPPORTED_TASKS:
|
||||
return
|
||||
|
||||
jdict = validator.jdict
|
||||
if not jdict:
|
||||
return
|
||||
|
||||
predictions_metadata_map = _create_prediction_metadata_map(jdict)
|
||||
dataloader = validator.dataloader
|
||||
class_label_map = validator.names
|
||||
class_map = getattr(validator, "class_map", None)
|
||||
|
||||
batch_logging_interval = _get_eval_batch_logging_interval()
|
||||
max_image_predictions = _get_max_image_predictions_to_log()
|
||||
|
||||
for batch_idx, batch in enumerate(dataloader):
|
||||
if (batch_idx + 1) % batch_logging_interval != 0:
|
||||
continue
|
||||
|
||||
image_paths = batch["im_file"]
|
||||
for img_idx, image_path in enumerate(image_paths):
|
||||
if _comet_image_prediction_count >= max_image_predictions:
|
||||
return
|
||||
|
||||
image_path = Path(image_path)
|
||||
annotations = _fetch_annotations(
|
||||
img_idx,
|
||||
image_path,
|
||||
batch,
|
||||
predictions_metadata_map,
|
||||
class_label_map,
|
||||
class_map=class_map,
|
||||
)
|
||||
_log_images(
|
||||
experiment,
|
||||
[image_path],
|
||||
curr_step,
|
||||
annotations=annotations,
|
||||
)
|
||||
_comet_image_prediction_count += 1
|
||||
|
||||
|
||||
def _log_plots(experiment, trainer) -> None:
|
||||
"""Log evaluation plots and label plots for the experiment.
|
||||
|
||||
This function logs various evaluation plots and confusion matrices to the experiment tracking system. It handles
|
||||
different types of metrics (SegmentMetrics, PoseMetrics, DetMetrics, OBBMetrics) and logs the appropriate plots for
|
||||
each type.
|
||||
|
||||
Args:
|
||||
experiment (comet_ml.CometExperiment): The Comet ML experiment to log plots to.
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The trainer object containing validation metrics and save
|
||||
directory information.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.callbacks.comet import _log_plots
|
||||
>>> _log_plots(experiment, trainer)
|
||||
"""
|
||||
plot_filenames = None
|
||||
if isinstance(trainer.validator.metrics, SegmentMetrics):
|
||||
plot_filenames = [
|
||||
trainer.save_dir / f"{prefix}{plots}.png"
|
||||
for plots in EVALUATION_PLOT_NAMES
|
||||
for prefix in SEGMENT_METRICS_PLOT_PREFIX
|
||||
]
|
||||
elif isinstance(trainer.validator.metrics, PoseMetrics):
|
||||
plot_filenames = [
|
||||
trainer.save_dir / f"{prefix}{plots}.png"
|
||||
for plots in EVALUATION_PLOT_NAMES
|
||||
for prefix in POSE_METRICS_PLOT_PREFIX
|
||||
]
|
||||
elif isinstance(trainer.validator.metrics, (DetMetrics, OBBMetrics)):
|
||||
plot_filenames = [
|
||||
trainer.save_dir / f"{prefix}{plots}.png"
|
||||
for plots in EVALUATION_PLOT_NAMES
|
||||
for prefix in DETECTION_METRICS_PLOT_PREFIX
|
||||
]
|
||||
|
||||
if plot_filenames is not None:
|
||||
_log_images(experiment, plot_filenames, None)
|
||||
|
||||
confusion_matrix_filenames = [trainer.save_dir / f"{plots}.png" for plots in CONFUSION_MATRIX_PLOT_NAMES]
|
||||
_log_images(experiment, confusion_matrix_filenames, None)
|
||||
|
||||
if not isinstance(trainer.validator.metrics, ClassifyMetrics):
|
||||
label_plot_filenames = [trainer.save_dir / f"{labels}.jpg" for labels in LABEL_PLOT_NAMES]
|
||||
_log_images(experiment, label_plot_filenames, None)
|
||||
|
||||
|
||||
def _log_model(experiment, trainer) -> None:
|
||||
"""Log the best-trained model to Comet.ml."""
|
||||
model_name = _get_comet_model_name()
|
||||
experiment.log_model(model_name, file_or_folder=str(trainer.best), file_name="best.pt", overwrite=True)
|
||||
|
||||
|
||||
def _log_image_batches(experiment, trainer, curr_step: int) -> None:
|
||||
"""Log samples of image batches for train and validation."""
|
||||
_log_images(experiment, trainer.save_dir.glob("train_batch*.jpg"), curr_step)
|
||||
_log_images(experiment, trainer.save_dir.glob("val_batch*.jpg"), curr_step)
|
||||
|
||||
|
||||
def _log_asset(experiment, asset_path) -> None:
|
||||
"""Logs a specific asset file to the given experiment.
|
||||
|
||||
This function facilitates logging an asset, such as a file, to the provided
|
||||
experiment. It enables integration with experiment tracking platforms.
|
||||
|
||||
Args:
|
||||
experiment (comet_ml.CometExperiment): The experiment instance to which the asset will be logged.
|
||||
asset_path (Path): The file path of the asset to log.
|
||||
"""
|
||||
experiment.log_asset(asset_path)
|
||||
|
||||
|
||||
def _log_table(experiment, table_path) -> None:
|
||||
"""Logs a table to the provided experiment.
|
||||
|
||||
This function is used to log a table file to the given experiment. The table is identified by its file path.
|
||||
|
||||
Args:
|
||||
experiment (comet_ml.CometExperiment): The experiment object where the table file will be logged.
|
||||
table_path (Path): The file path of the table to be logged.
|
||||
"""
|
||||
experiment.log_table(str(table_path))
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer) -> None:
|
||||
"""Create or resume a CometML experiment at the start of a YOLO pre-training routine."""
|
||||
_resume_or_create_experiment(trainer.args)
|
||||
|
||||
|
||||
def on_train_epoch_end(trainer) -> None:
|
||||
"""Log metrics and save batch images at the end of training epochs."""
|
||||
experiment = comet_ml.get_running_experiment()
|
||||
if not experiment:
|
||||
return
|
||||
|
||||
metadata = _fetch_trainer_metadata(trainer)
|
||||
curr_epoch = metadata["curr_epoch"]
|
||||
curr_step = metadata["curr_step"]
|
||||
|
||||
experiment.log_metrics(trainer.label_loss_items(trainer.tloss, prefix="train"), step=curr_step, epoch=curr_epoch)
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer) -> None:
|
||||
"""Log model assets at the end of each epoch during training.
|
||||
|
||||
This function is called at the end of each training epoch to log metrics, learning rates, and model information to a
|
||||
Comet ML experiment. It also logs model assets, confusion matrices, and image predictions based on configuration
|
||||
settings.
|
||||
|
||||
The function retrieves the current Comet ML experiment and logs various training metrics. If it's the first epoch,
|
||||
it also logs model information. On specified save intervals, it logs the model, confusion matrix (if enabled), and
|
||||
image predictions (if enabled).
|
||||
|
||||
Args:
|
||||
trainer (BaseTrainer): The YOLO trainer object containing training state, metrics, and configuration.
|
||||
|
||||
Examples:
|
||||
>>> # Inside a training loop
|
||||
>>> on_fit_epoch_end(trainer) # Log metrics and assets to Comet ML
|
||||
"""
|
||||
experiment = comet_ml.get_running_experiment()
|
||||
if not experiment:
|
||||
return
|
||||
|
||||
metadata = _fetch_trainer_metadata(trainer)
|
||||
curr_epoch = metadata["curr_epoch"]
|
||||
curr_step = metadata["curr_step"]
|
||||
save_assets = metadata["save_assets"]
|
||||
|
||||
experiment.log_metrics(trainer.metrics, step=curr_step, epoch=curr_epoch)
|
||||
experiment.log_metrics(trainer.lr, step=curr_step, epoch=curr_epoch)
|
||||
if curr_epoch == 1:
|
||||
from ultralytics.utils.torch_utils import model_info_for_loggers
|
||||
|
||||
experiment.log_metrics(model_info_for_loggers(trainer), step=curr_step, epoch=curr_epoch)
|
||||
|
||||
if not save_assets:
|
||||
return
|
||||
|
||||
_log_model(experiment, trainer)
|
||||
if _should_log_confusion_matrix():
|
||||
_log_confusion_matrix(experiment, trainer, curr_step, curr_epoch)
|
||||
if _should_log_image_predictions():
|
||||
_log_image_predictions(experiment, trainer.validator, curr_step)
|
||||
|
||||
|
||||
def on_train_end(trainer) -> None:
|
||||
"""Perform operations at the end of training."""
|
||||
experiment = comet_ml.get_running_experiment()
|
||||
if not experiment:
|
||||
return
|
||||
|
||||
metadata = _fetch_trainer_metadata(trainer)
|
||||
curr_epoch = metadata["curr_epoch"]
|
||||
curr_step = metadata["curr_step"]
|
||||
plots = trainer.args.plots
|
||||
|
||||
_log_model(experiment, trainer)
|
||||
if plots:
|
||||
_log_plots(experiment, trainer)
|
||||
|
||||
_log_confusion_matrix(experiment, trainer, curr_step, curr_epoch)
|
||||
_log_image_predictions(experiment, trainer.validator, curr_step)
|
||||
_log_image_batches(experiment, trainer, curr_step)
|
||||
# log results table
|
||||
table_path = trainer.save_dir / RESULTS_TABLE_NAME
|
||||
if table_path.exists():
|
||||
_log_table(experiment, table_path)
|
||||
|
||||
# log arguments YAML
|
||||
args_path = trainer.save_dir / ARGS_YAML_NAME
|
||||
if args_path.exists():
|
||||
_log_asset(experiment, args_path)
|
||||
|
||||
experiment.end()
|
||||
|
||||
global _comet_image_prediction_count
|
||||
_comet_image_prediction_count = 0
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_train_epoch_end": on_train_epoch_end,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if comet_ml
|
||||
else {}
|
||||
)
|
||||
197
ultralytics/utils/callbacks/dvc.py
Executable file
197
ultralytics/utils/callbacks/dvc.py
Executable file
@@ -0,0 +1,197 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ultralytics.utils import LOGGER, SETTINGS, TESTS_RUNNING, checks
|
||||
|
||||
try:
|
||||
assert not TESTS_RUNNING # do not log pytest
|
||||
assert SETTINGS["dvc"] is True # verify integration is enabled
|
||||
import dvclive
|
||||
|
||||
assert checks.check_version("dvclive", "2.11.0", verbose=True)
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
# DVCLive logger instance
|
||||
live = None
|
||||
_processed_plots = {}
|
||||
|
||||
# `on_fit_epoch_end` is called on final validation (probably need to be fixed) for now this is the way we
|
||||
# distinguish final evaluation of the best model vs last epoch validation
|
||||
_training_epoch = False
|
||||
|
||||
except (ImportError, AssertionError, TypeError):
|
||||
dvclive = None
|
||||
|
||||
|
||||
def _log_images(path: Path, prefix: str = "") -> None:
|
||||
"""Log images at specified path with an optional prefix using DVCLive.
|
||||
|
||||
This function logs images found at the given path to DVCLive, organizing them by batch to enable slider
|
||||
functionality in the UI. It processes image filenames to extract batch information and restructures the path
|
||||
accordingly.
|
||||
|
||||
Args:
|
||||
path (Path): Path to the image file to be logged.
|
||||
prefix (str, optional): Optional prefix to add to the image name when logging.
|
||||
|
||||
Examples:
|
||||
>>> from pathlib import Path
|
||||
>>> _log_images(Path("runs/train/exp/val_batch0_pred.jpg"), prefix="validation")
|
||||
"""
|
||||
if live:
|
||||
name = path.name
|
||||
|
||||
# Group images by batch to enable sliders in UI
|
||||
if m := re.search(r"_batch(\d+)", name):
|
||||
ni = m[1]
|
||||
new_stem = re.sub(r"_batch(\d+)", "_batch", path.stem)
|
||||
name = (Path(new_stem) / ni).with_suffix(path.suffix)
|
||||
|
||||
live.log_image(os.path.join(prefix, name), path)
|
||||
|
||||
|
||||
def _log_plots(plots: dict, prefix: str = "") -> None:
|
||||
"""Log plot images for training progress if they have not been previously processed.
|
||||
|
||||
Args:
|
||||
plots (dict): Dictionary containing plot information with timestamps.
|
||||
prefix (str, optional): Optional prefix to add to the logged image paths.
|
||||
"""
|
||||
for name, params in plots.items():
|
||||
timestamp = params["timestamp"]
|
||||
if _processed_plots.get(name) != timestamp:
|
||||
_log_images(name, prefix)
|
||||
_processed_plots[name] = timestamp
|
||||
|
||||
|
||||
def _log_confusion_matrix(validator) -> None:
|
||||
"""Log confusion matrix for a validator using DVCLive.
|
||||
|
||||
This function processes the confusion matrix from a validator object and logs it to DVCLive by converting the matrix
|
||||
into lists of target and prediction labels.
|
||||
|
||||
Args:
|
||||
validator (BaseValidator): The validator object containing the confusion matrix and class names. Must have
|
||||
attributes confusion_matrix.matrix, confusion_matrix.task, and names.
|
||||
"""
|
||||
targets = []
|
||||
preds = []
|
||||
matrix = validator.confusion_matrix.matrix
|
||||
names = list(validator.names.values())
|
||||
if validator.confusion_matrix.task == "detect":
|
||||
names += ["background"]
|
||||
|
||||
for ti, pred in enumerate(matrix.T.astype(int)):
|
||||
for pi, num in enumerate(pred):
|
||||
targets.extend([names[ti]] * num)
|
||||
preds.extend([names[pi]] * num)
|
||||
|
||||
live.log_sklearn_plot("confusion_matrix", targets, preds, name="cf.json", normalized=True)
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer) -> None:
|
||||
"""Initialize DVCLive logger for training metadata during pre-training routine."""
|
||||
try:
|
||||
global live
|
||||
live = dvclive.Live(save_dvc_exp=True, cache_images=True)
|
||||
LOGGER.info("DVCLive is detected and auto logging is enabled (run 'yolo settings dvc=False' to disable).")
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"DVCLive installed but not initialized correctly, not logging this run. {e}")
|
||||
|
||||
|
||||
def on_pretrain_routine_end(trainer) -> None:
|
||||
"""Log plots related to the training process at the end of the pretraining routine."""
|
||||
_log_plots(trainer.plots, "train")
|
||||
|
||||
|
||||
def on_train_start(trainer) -> None:
|
||||
"""Log the training parameters if DVCLive logging is active."""
|
||||
if live:
|
||||
live.log_params(trainer.args)
|
||||
|
||||
|
||||
def on_train_epoch_start(trainer) -> None:
|
||||
"""Set the global variable _training_epoch value to True at the start of each training epoch."""
|
||||
global _training_epoch
|
||||
_training_epoch = True
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer) -> None:
|
||||
"""Log training metrics, model info, and advance to next step at the end of each fit epoch.
|
||||
|
||||
This function is called at the end of each fit epoch during training. It logs various metrics including training
|
||||
loss items, validation metrics, and learning rates. On the first epoch, it also logs model
|
||||
information. Additionally, it logs training and validation plots and advances the DVCLive step counter.
|
||||
|
||||
Args:
|
||||
trainer (BaseTrainer): The trainer object containing training state, metrics, and plots.
|
||||
|
||||
Notes:
|
||||
This function only performs logging operations when DVCLive logging is active and during a training epoch.
|
||||
The global variable _training_epoch is used to track whether the current epoch is a training epoch.
|
||||
"""
|
||||
global _training_epoch
|
||||
if live and _training_epoch:
|
||||
all_metrics = {**trainer.label_loss_items(trainer.tloss, prefix="train"), **trainer.metrics, **trainer.lr}
|
||||
for metric, value in all_metrics.items():
|
||||
live.log_metric(metric, value)
|
||||
|
||||
if trainer.epoch == 0:
|
||||
from ultralytics.utils.torch_utils import model_info_for_loggers
|
||||
|
||||
for metric, value in model_info_for_loggers(trainer).items():
|
||||
live.log_metric(metric, value, plot=False)
|
||||
|
||||
_log_plots(trainer.plots, "train")
|
||||
_log_plots(trainer.validator.plots, "val")
|
||||
|
||||
live.next_step()
|
||||
_training_epoch = False
|
||||
|
||||
|
||||
def on_train_end(trainer) -> None:
|
||||
"""Log best metrics, plots, and confusion matrix at the end of training.
|
||||
|
||||
This function is called at the conclusion of the training process to log final metrics, visualizations, and model
|
||||
artifacts if DVCLive logging is active. It captures the best model performance metrics, training plots, validation
|
||||
plots, and confusion matrix for later analysis.
|
||||
|
||||
Args:
|
||||
trainer (BaseTrainer): The trainer object containing training state, metrics, and validation results.
|
||||
|
||||
Examples:
|
||||
>>> # Inside a custom training loop
|
||||
>>> from ultralytics.utils.callbacks.dvc import on_train_end
|
||||
>>> on_train_end(trainer) # Log final metrics and artifacts
|
||||
"""
|
||||
if live:
|
||||
# At the end log the best metrics. It runs validator on the best model internally.
|
||||
all_metrics = {**trainer.label_loss_items(trainer.tloss, prefix="train"), **trainer.metrics, **trainer.lr}
|
||||
for metric, value in all_metrics.items():
|
||||
live.log_metric(metric, value, plot=False)
|
||||
|
||||
_log_plots(trainer.plots, "val")
|
||||
_log_plots(trainer.validator.plots, "val")
|
||||
_log_confusion_matrix(trainer.validator)
|
||||
|
||||
if trainer.best.exists():
|
||||
live.log_artifact(trainer.best, copy=True, type="model")
|
||||
|
||||
live.end()
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_pretrain_routine_end": on_pretrain_routine_end,
|
||||
"on_train_start": on_train_start,
|
||||
"on_train_epoch_start": on_train_epoch_start,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if dvclive
|
||||
else {}
|
||||
)
|
||||
110
ultralytics/utils/callbacks/hub.py
Executable file
110
ultralytics/utils/callbacks/hub.py
Executable file
@@ -0,0 +1,110 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
import json
|
||||
from time import time
|
||||
|
||||
from ultralytics.hub import HUB_WEB_ROOT, PREFIX, HUBTrainingSession
|
||||
from ultralytics.utils import LOGGER, RANK, SETTINGS
|
||||
from ultralytics.utils.events import events
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer):
|
||||
"""Create a remote Ultralytics HUB session to log local model training."""
|
||||
if RANK in {-1, 0} and SETTINGS["hub"] is True and SETTINGS["api_key"] and trainer.hub_session is None:
|
||||
trainer.hub_session = HUBTrainingSession.create_session(trainer.args.model, trainer.args)
|
||||
|
||||
|
||||
def on_pretrain_routine_end(trainer):
|
||||
"""Initialize timers for upload rate limiting before training begins."""
|
||||
if session := getattr(trainer, "hub_session", None):
|
||||
# Start timer for upload rate limit
|
||||
session.timers = {"metrics": time(), "ckpt": time()} # start timer for session rate limiting
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
"""Upload training progress metrics to Ultralytics HUB at the end of each epoch."""
|
||||
if session := getattr(trainer, "hub_session", None):
|
||||
# Upload metrics after validation ends
|
||||
all_plots = {
|
||||
**trainer.label_loss_items(trainer.tloss, prefix="train"),
|
||||
**trainer.metrics,
|
||||
}
|
||||
if trainer.epoch == 0:
|
||||
from ultralytics.utils.torch_utils import model_info_for_loggers
|
||||
|
||||
all_plots = {**all_plots, **model_info_for_loggers(trainer)}
|
||||
|
||||
session.metrics_queue[trainer.epoch] = json.dumps(all_plots)
|
||||
|
||||
# If any metrics failed to upload previously, add them to the queue to attempt uploading again
|
||||
if session.metrics_upload_failed_queue:
|
||||
session.metrics_queue.update(session.metrics_upload_failed_queue)
|
||||
|
||||
if time() - session.timers["metrics"] > session.rate_limits["metrics"]:
|
||||
session.upload_metrics()
|
||||
session.timers["metrics"] = time() # reset timer
|
||||
session.metrics_queue = {} # reset queue
|
||||
|
||||
|
||||
def on_model_save(trainer):
|
||||
"""Upload model checkpoints to Ultralytics HUB with rate limiting."""
|
||||
if session := getattr(trainer, "hub_session", None):
|
||||
# Upload checkpoints with rate limiting
|
||||
is_best = trainer.best_fitness == trainer.fitness
|
||||
if time() - session.timers["ckpt"] > session.rate_limits["ckpt"]:
|
||||
LOGGER.info(f"{PREFIX}Uploading checkpoint {HUB_WEB_ROOT}/models/{session.model.id}")
|
||||
session.upload_model(trainer.epoch, trainer.last, is_best)
|
||||
session.timers["ckpt"] = time() # reset timer
|
||||
|
||||
|
||||
def on_train_end(trainer):
|
||||
"""Upload final model and metrics to Ultralytics HUB at the end of training."""
|
||||
if session := getattr(trainer, "hub_session", None):
|
||||
# Upload final model and metrics with exponential standoff
|
||||
LOGGER.info(f"{PREFIX}Syncing final model...")
|
||||
session.upload_model(
|
||||
trainer.epoch,
|
||||
trainer.best,
|
||||
map=trainer.metrics.get("metrics/mAP50-95(B)", 0),
|
||||
final=True,
|
||||
)
|
||||
session.alive = False # stop heartbeats
|
||||
LOGGER.info(f"{PREFIX}Done ✅\n{PREFIX}View model at {session.model_url} 🚀")
|
||||
|
||||
|
||||
def on_train_start(trainer):
|
||||
"""Run events on train start."""
|
||||
events(trainer.args, trainer.device)
|
||||
|
||||
|
||||
def on_val_start(validator):
|
||||
"""Run events on validation start."""
|
||||
if not validator.training:
|
||||
events(validator.args, validator.device)
|
||||
|
||||
|
||||
def on_predict_start(predictor):
|
||||
"""Run events on predict start."""
|
||||
events(predictor.args, predictor.device)
|
||||
|
||||
|
||||
def on_export_start(exporter):
|
||||
"""Run events on export start."""
|
||||
events(exporter.args, exporter.device)
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_pretrain_routine_end": on_pretrain_routine_end,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_model_save": on_model_save,
|
||||
"on_train_end": on_train_end,
|
||||
"on_train_start": on_train_start,
|
||||
"on_val_start": on_val_start,
|
||||
"on_predict_start": on_predict_start,
|
||||
"on_export_start": on_export_start,
|
||||
}
|
||||
if SETTINGS["hub"] is True
|
||||
else {}
|
||||
)
|
||||
134
ultralytics/utils/callbacks/mlflow.py
Executable file
134
ultralytics/utils/callbacks/mlflow.py
Executable file
@@ -0,0 +1,134 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
"""
|
||||
MLflow Logging for Ultralytics YOLO.
|
||||
|
||||
This module enables MLflow logging for Ultralytics YOLO. It logs metrics, parameters, and model artifacts.
|
||||
For setting up, a tracking URI should be specified. The logging can be customized using environment variables.
|
||||
|
||||
Commands:
|
||||
1. To set a project name:
|
||||
`export MLFLOW_EXPERIMENT_NAME=<your_experiment_name>` or use the project=<project> argument
|
||||
|
||||
2. To set a run name:
|
||||
`export MLFLOW_RUN=<your_run_name>` or use the name=<name> argument
|
||||
|
||||
3. To start a local MLflow server:
|
||||
mlflow server --backend-store-uri runs/mlflow
|
||||
It will by default start a local server at http://127.0.0.1:5000.
|
||||
To specify a different URI, set the MLFLOW_TRACKING_URI environment variable.
|
||||
|
||||
4. To kill all running MLflow server instances:
|
||||
ps aux | grep 'mlflow' | grep -v 'grep' | awk '{print $2}' | xargs kill -9
|
||||
"""
|
||||
|
||||
from ultralytics.utils import LOGGER, RUNS_DIR, SETTINGS, TESTS_RUNNING, colorstr
|
||||
|
||||
try:
|
||||
import os
|
||||
|
||||
assert not TESTS_RUNNING or "test_mlflow" in os.environ.get("PYTEST_CURRENT_TEST", "") # do not log pytest
|
||||
assert SETTINGS["mlflow"] is True # verify integration is enabled
|
||||
import mlflow
|
||||
|
||||
assert hasattr(mlflow, "__version__") # verify package is not directory
|
||||
from pathlib import Path
|
||||
|
||||
PREFIX = colorstr("MLflow: ")
|
||||
|
||||
except (ImportError, AssertionError):
|
||||
mlflow = None
|
||||
|
||||
|
||||
def sanitize_dict(x: dict) -> dict:
|
||||
"""Sanitize dictionary keys by removing parentheses and converting values to floats."""
|
||||
return {k.replace("(", "").replace(")", ""): float(v) for k, v in x.items()}
|
||||
|
||||
|
||||
def on_pretrain_routine_end(trainer):
|
||||
"""Log training parameters to MLflow at the end of the pretraining routine.
|
||||
|
||||
This function sets up MLflow logging based on environment variables and trainer arguments. It sets the tracking URI,
|
||||
experiment name, and run name, then starts the MLflow run if not already active. It finally logs the parameters from
|
||||
the trainer.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The training object with arguments and parameters to log.
|
||||
|
||||
Notes:
|
||||
MLFLOW_TRACKING_URI: The URI for MLflow tracking. If not set, defaults to 'runs/mlflow'.
|
||||
MLFLOW_EXPERIMENT_NAME: The name of the MLflow experiment. If not set, defaults to trainer.args.project.
|
||||
MLFLOW_RUN: The name of the MLflow run. If not set, defaults to trainer.args.name.
|
||||
MLFLOW_KEEP_RUN_ACTIVE: Boolean indicating whether to keep the MLflow run active after training ends.
|
||||
"""
|
||||
global mlflow
|
||||
|
||||
uri = os.environ.get("MLFLOW_TRACKING_URI") or str(RUNS_DIR / "mlflow")
|
||||
LOGGER.debug(f"{PREFIX} tracking uri: {uri}")
|
||||
mlflow.set_tracking_uri(uri)
|
||||
|
||||
# Set experiment and run names
|
||||
experiment_name = os.environ.get("MLFLOW_EXPERIMENT_NAME") or trainer.args.project or "/Shared/Ultralytics"
|
||||
run_name = os.environ.get("MLFLOW_RUN") or trainer.args.name
|
||||
mlflow.set_experiment(experiment_name)
|
||||
|
||||
mlflow.autolog()
|
||||
try:
|
||||
active_run = mlflow.active_run() or mlflow.start_run(run_name=run_name)
|
||||
LOGGER.info(f"{PREFIX}logging run_id({active_run.info.run_id}) to {uri}")
|
||||
if Path(uri).is_dir():
|
||||
LOGGER.info(f"{PREFIX}view at http://127.0.0.1:5000 with 'mlflow server --backend-store-uri {uri}'")
|
||||
LOGGER.info(f"{PREFIX}disable with 'yolo settings mlflow=False'")
|
||||
mlflow.log_params(dict(trainer.args))
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{PREFIX}Failed to initialize: {e}")
|
||||
LOGGER.warning(f"{PREFIX}Not tracking this run")
|
||||
|
||||
|
||||
def on_train_epoch_end(trainer):
|
||||
"""Log training metrics at the end of each train epoch to MLflow."""
|
||||
if mlflow:
|
||||
mlflow.log_metrics(
|
||||
metrics={
|
||||
**sanitize_dict(trainer.lr),
|
||||
**sanitize_dict(trainer.label_loss_items(trainer.tloss, prefix="train")),
|
||||
},
|
||||
step=trainer.epoch,
|
||||
)
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
"""Log training metrics at the end of each fit epoch to MLflow."""
|
||||
if mlflow:
|
||||
mlflow.log_metrics(metrics=sanitize_dict(trainer.metrics), step=trainer.epoch)
|
||||
|
||||
|
||||
def on_train_end(trainer):
|
||||
"""Log model artifacts at the end of training."""
|
||||
if not mlflow:
|
||||
return
|
||||
mlflow.log_artifact(str(trainer.best.parent)) # log save_dir/weights directory with best.pt and last.pt
|
||||
for f in trainer.save_dir.glob("*"): # log all other files in save_dir
|
||||
if f.suffix in {".png", ".jpg", ".csv", ".pt", ".yaml"}:
|
||||
mlflow.log_artifact(str(f))
|
||||
keep_run_active = os.environ.get("MLFLOW_KEEP_RUN_ACTIVE", "False").lower() == "true"
|
||||
if keep_run_active:
|
||||
LOGGER.info(f"{PREFIX}mlflow run still alive, remember to close it using mlflow.end_run()")
|
||||
else:
|
||||
mlflow.end_run()
|
||||
LOGGER.debug(f"{PREFIX}mlflow run ended")
|
||||
|
||||
LOGGER.info(
|
||||
f"{PREFIX}results logged to {mlflow.get_tracking_uri()}\n{PREFIX}disable with 'yolo settings mlflow=False'"
|
||||
)
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_end": on_pretrain_routine_end,
|
||||
"on_train_epoch_end": on_train_epoch_end,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if mlflow
|
||||
else {}
|
||||
)
|
||||
126
ultralytics/utils/callbacks/neptune.py
Executable file
126
ultralytics/utils/callbacks/neptune.py
Executable file
@@ -0,0 +1,126 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from ultralytics.utils import LOGGER, SETTINGS, TESTS_RUNNING
|
||||
|
||||
try:
|
||||
assert not TESTS_RUNNING # do not log pytest
|
||||
assert SETTINGS["neptune"] is True # verify integration is enabled
|
||||
|
||||
import neptune
|
||||
from neptune.types import File
|
||||
|
||||
assert hasattr(neptune, "__version__")
|
||||
|
||||
run = None # NeptuneAI experiment logger instance
|
||||
|
||||
except (ImportError, AssertionError):
|
||||
neptune = None
|
||||
|
||||
|
||||
def _log_scalars(scalars: dict, step: int = 0) -> None:
|
||||
"""Log scalars to the NeptuneAI experiment logger.
|
||||
|
||||
Args:
|
||||
scalars (dict): Dictionary of scalar values to log to NeptuneAI.
|
||||
step (int, optional): The current step or iteration number for logging.
|
||||
|
||||
Examples:
|
||||
>>> metrics = {"mAP": 0.85, "loss": 0.32}
|
||||
>>> _log_scalars(metrics, step=100)
|
||||
"""
|
||||
if run:
|
||||
for k, v in scalars.items():
|
||||
run[k].append(value=v, step=step)
|
||||
|
||||
|
||||
def _log_images(imgs_dict: dict, group: str = "") -> None:
|
||||
"""Log images to the NeptuneAI experiment logger.
|
||||
|
||||
This function logs image data to Neptune.ai when a valid Neptune run is active. Images are organized under the
|
||||
specified group name.
|
||||
|
||||
Args:
|
||||
imgs_dict (dict): Dictionary of images to log, with keys as image names and values as image data.
|
||||
group (str, optional): Group name to organize images under in the Neptune UI.
|
||||
|
||||
Examples:
|
||||
>>> # Log validation images
|
||||
>>> _log_images({"val_batch": img_tensor}, group="validation")
|
||||
"""
|
||||
if run:
|
||||
for k, v in imgs_dict.items():
|
||||
run[f"{group}/{k}"].upload(File(v))
|
||||
|
||||
|
||||
def _log_plot(title: str, plot_path: str) -> None:
|
||||
"""Log plots to the NeptuneAI experiment logger."""
|
||||
import matplotlib.image as mpimg
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
img = mpimg.imread(plot_path)
|
||||
fig = plt.figure()
|
||||
ax = fig.add_axes([0, 0, 1, 1], frameon=False, aspect="auto", xticks=[], yticks=[]) # no ticks
|
||||
ax.imshow(img)
|
||||
run[f"Plots/{title}"].upload(fig)
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer) -> None:
|
||||
"""Initialize NeptuneAI run and log hyperparameters before training starts."""
|
||||
try:
|
||||
global run
|
||||
run = neptune.init_run(
|
||||
project=trainer.args.project or "Ultralytics",
|
||||
name=trainer.args.name,
|
||||
tags=["Ultralytics"],
|
||||
)
|
||||
run["Configuration/Hyperparameters"] = {k: "" if v is None else v for k, v in vars(trainer.args).items()}
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"NeptuneAI installed but not initialized correctly, not logging this run. {e}")
|
||||
|
||||
|
||||
def on_train_epoch_end(trainer) -> None:
|
||||
"""Log training metrics and learning rate at the end of each training epoch."""
|
||||
_log_scalars(trainer.label_loss_items(trainer.tloss, prefix="train"), trainer.epoch + 1)
|
||||
_log_scalars(trainer.lr, trainer.epoch + 1)
|
||||
if trainer.epoch == 1:
|
||||
_log_images({f.stem: str(f) for f in trainer.save_dir.glob("train_batch*.jpg")}, "Mosaic")
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer) -> None:
|
||||
"""Log model info and validation metrics at the end of each fit epoch."""
|
||||
if run and trainer.epoch == 0:
|
||||
from ultralytics.utils.torch_utils import model_info_for_loggers
|
||||
|
||||
run["Configuration/Model"] = model_info_for_loggers(trainer)
|
||||
_log_scalars(trainer.metrics, trainer.epoch + 1)
|
||||
|
||||
|
||||
def on_val_end(validator) -> None:
|
||||
"""Log validation images at the end of validation."""
|
||||
if run:
|
||||
# Log val_labels and val_pred
|
||||
_log_images({f.stem: str(f) for f in validator.save_dir.glob("val*.jpg")}, "Validation")
|
||||
|
||||
|
||||
def on_train_end(trainer) -> None:
|
||||
"""Log final results, plots, and model weights at the end of training."""
|
||||
if run:
|
||||
# Log final results, CM matrix + PR plots
|
||||
for f in [*trainer.plots.keys(), *trainer.validator.plots.keys()]:
|
||||
if "batch" not in f.name:
|
||||
_log_plot(title=f.stem, plot_path=f)
|
||||
# Log the final model
|
||||
run[f"weights/{trainer.args.name or trainer.args.task}/{trainer.best.name}"].upload(File(str(trainer.best)))
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_train_epoch_end": on_train_epoch_end,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_val_end": on_val_end,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if neptune
|
||||
else {}
|
||||
)
|
||||
498
ultralytics/utils/callbacks/platform.py
Executable file
498
ultralytics/utils/callbacks/platform.py
Executable file
@@ -0,0 +1,498 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
from ultralytics.utils import ENVIRONMENT, GIT, LOGGER, PYTHON_VERSION, RANK, SETTINGS, TESTS_RUNNING, Retry, colorstr
|
||||
|
||||
PREFIX = colorstr("Platform: ")
|
||||
|
||||
# Configurable platform URL for debugging (e.g. ULTRALYTICS_PLATFORM_URL=http://localhost:3000)
|
||||
PLATFORM_URL = os.getenv("ULTRALYTICS_PLATFORM_URL", "https://platform.ultralytics.com").rstrip("/")
|
||||
PLATFORM_API_URL = f"{PLATFORM_URL}/api/webhooks"
|
||||
|
||||
|
||||
def slugify(text):
|
||||
"""Convert text to URL-safe slug (e.g., 'My Project 1' -> 'my-project-1')."""
|
||||
if not text:
|
||||
return text
|
||||
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9\s-]", "", str(text).lower()).replace(" ", "-")).strip("-")[:128]
|
||||
|
||||
|
||||
try:
|
||||
assert not TESTS_RUNNING # do not log pytest
|
||||
assert SETTINGS.get("platform", False) is True or os.getenv("ULTRALYTICS_API_KEY") or SETTINGS.get("api_key")
|
||||
_api_key = os.getenv("ULTRALYTICS_API_KEY") or SETTINGS.get("api_key")
|
||||
assert _api_key # verify API key is present
|
||||
|
||||
import requests
|
||||
|
||||
from ultralytics.utils.logger import ConsoleLogger, SystemLogger
|
||||
from ultralytics.utils.torch_utils import model_info_for_loggers
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=10) # Bounded thread pool for async operations
|
||||
|
||||
except (AssertionError, ImportError):
|
||||
_api_key = None
|
||||
|
||||
|
||||
def resolve_platform_uri(uri, hard=True):
|
||||
"""Resolve ul:// URIs to signed URLs by authenticating with Ultralytics Platform.
|
||||
|
||||
Formats:
|
||||
ul://username/datasets/slug -> Returns signed URL to NDJSON file
|
||||
ul://username/project/model -> Returns signed URL to .pt file
|
||||
|
||||
Args:
|
||||
uri (str): Platform URI starting with "ul://".
|
||||
hard (bool): Whether to raise an error if resolution fails.
|
||||
|
||||
Returns:
|
||||
(str | None): Signed URL on success, None if not found and hard=False.
|
||||
|
||||
Raises:
|
||||
ValueError: If API key is missing/invalid or URI format is wrong.
|
||||
PermissionError: If access is denied.
|
||||
RuntimeError: If resource is not ready (e.g., dataset still processing).
|
||||
FileNotFoundError: If resource not found and hard=True.
|
||||
ConnectionError: If network request fails and hard=True.
|
||||
"""
|
||||
import requests
|
||||
|
||||
path = uri[5:] # Remove "ul://"
|
||||
parts = path.split("/")
|
||||
|
||||
api_key = os.getenv("ULTRALYTICS_API_KEY") or SETTINGS.get("api_key")
|
||||
if not api_key:
|
||||
raise ValueError(f"ULTRALYTICS_API_KEY required for '{uri}'. Get key at {PLATFORM_URL}/settings")
|
||||
|
||||
base = PLATFORM_API_URL
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
# ul://username/datasets/slug
|
||||
if len(parts) == 3 and parts[1] == "datasets":
|
||||
username, _, slug = parts
|
||||
url = f"{base}/datasets/{username}/{slug}/export"
|
||||
|
||||
# ul://username/project/model
|
||||
elif len(parts) == 3:
|
||||
username, project, model = parts
|
||||
url = f"{base}/models/{username}/{project}/{model}/download"
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid platform URI: {uri}. Use ul://user/datasets/name or ul://user/project/model")
|
||||
|
||||
try:
|
||||
timeout = 3600 if "/datasets/" in url else 90 # NDJSON generation can be slow for large datasets
|
||||
r = requests.head(url, headers=headers, allow_redirects=False, timeout=timeout)
|
||||
|
||||
# Handle redirect responses (301, 302, 303, 307, 308)
|
||||
if 300 <= r.status_code < 400 and "location" in r.headers:
|
||||
return r.headers["location"] # Return signed URL
|
||||
|
||||
# Handle error responses
|
||||
if r.status_code == 401:
|
||||
raise ValueError(f"Invalid ULTRALYTICS_API_KEY for '{uri}'")
|
||||
if r.status_code == 403:
|
||||
raise PermissionError(f"Access denied for '{uri}'. Check dataset/model visibility settings.")
|
||||
if r.status_code == 404:
|
||||
if hard:
|
||||
raise FileNotFoundError(f"Not found on platform: {uri}")
|
||||
LOGGER.warning(f"Not found on platform: {uri}")
|
||||
return None
|
||||
if r.status_code == 409:
|
||||
raise RuntimeError(f"Resource not ready: {uri}. Dataset may still be processing.")
|
||||
|
||||
# Unexpected response
|
||||
r.raise_for_status()
|
||||
raise RuntimeError(f"Unexpected response from platform for '{uri}': {r.status_code}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if hard:
|
||||
raise ConnectionError(f"Failed to resolve {uri}: {e}") from e
|
||||
LOGGER.warning(f"Failed to resolve {uri}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _interp_plot(plot, n=101):
|
||||
"""Interpolate plot curve data to n points to reduce storage size."""
|
||||
import numpy as np
|
||||
|
||||
if not plot.get("x") or not plot.get("y"):
|
||||
return plot # No interpolation needed (e.g., confusion_matrix)
|
||||
|
||||
x, y = np.array(plot["x"]), np.array(plot["y"])
|
||||
if len(x) <= n:
|
||||
return plot # Already small enough
|
||||
|
||||
# New x values (101 points gives clean 0.01 increments: 0, 0.01, 0.02, ..., 1.0)
|
||||
x_new = np.linspace(x[0], x[-1], n)
|
||||
|
||||
# Interpolate y values (handle both 1D and 2D arrays)
|
||||
if y.ndim == 1:
|
||||
y_new = np.interp(x_new, x, y)
|
||||
else:
|
||||
y_new = np.array([np.interp(x_new, x, yi) for yi in y])
|
||||
|
||||
# Also interpolate ap if present (for PR curves)
|
||||
result = {**plot, "x": x_new.tolist(), "y": y_new.tolist()}
|
||||
if "ap" in plot:
|
||||
result["ap"] = plot["ap"] # Keep AP values as-is (per-class scalars)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _send(event, data, project, name, model_id=None, retry=2):
|
||||
"""Send event to Platform endpoint with retry logic."""
|
||||
payload = {"event": event, "project": project, "name": name, "data": data}
|
||||
if model_id:
|
||||
payload["modelId"] = model_id
|
||||
|
||||
@Retry(times=retry, delay=1)
|
||||
def post():
|
||||
r = requests.post(
|
||||
f"{PLATFORM_API_URL}/training/metrics",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {_api_key}"},
|
||||
timeout=30,
|
||||
)
|
||||
if 400 <= r.status_code < 500 and r.status_code not in {408, 429}:
|
||||
LOGGER.warning(f"{PREFIX}Failed to send {event}: {r.status_code} {r.reason}")
|
||||
return None # Don't retry client errors (except 408 timeout, 429 rate limit)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
try:
|
||||
return post()
|
||||
except Exception as e:
|
||||
LOGGER.debug(f"{PREFIX}Failed to send {event}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _send_async(event, data, project, name, model_id=None):
|
||||
"""Send event asynchronously using bounded thread pool."""
|
||||
_executor.submit(_send, event, data, project, name, model_id)
|
||||
|
||||
|
||||
def _upload_model(model_path, project, name, progress=False, retry=1, model_id=None):
|
||||
"""Upload model checkpoint to Platform via signed URL."""
|
||||
from ultralytics.utils.uploads import safe_upload
|
||||
|
||||
model_path = Path(model_path)
|
||||
if not model_path.exists():
|
||||
LOGGER.warning(f"{PREFIX}Model file not found: {model_path}")
|
||||
return None
|
||||
|
||||
# Get signed upload URL from Platform (server sanitizes filename for storage safety)
|
||||
@Retry(times=3, delay=2)
|
||||
def get_signed_url():
|
||||
payload = {"project": project, "name": name, "filename": model_path.name}
|
||||
if model_id:
|
||||
payload["modelId"] = model_id # Direct lookup avoids slug mismatch from auto-increment
|
||||
r = requests.post(
|
||||
f"{PLATFORM_API_URL}/models/upload",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {_api_key}"},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
try:
|
||||
data = get_signed_url()
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{PREFIX}Failed to get upload URL: {e}")
|
||||
return None
|
||||
|
||||
# Upload to GCS using safe_upload with retry logic and optional progress bar
|
||||
if safe_upload(file=model_path, url=data["uploadUrl"], retry=retry, progress=progress):
|
||||
return data.get("gcsPath")
|
||||
return None
|
||||
|
||||
|
||||
def _upload_model_async(model_path, project, name, model_id=None):
|
||||
"""Upload model asynchronously using bounded thread pool."""
|
||||
_executor.submit(_upload_model, model_path, project, name, model_id=model_id)
|
||||
|
||||
|
||||
def _get_environment_info():
|
||||
"""Collect comprehensive environment info using existing ultralytics utilities."""
|
||||
import shutil
|
||||
|
||||
import psutil
|
||||
import torch
|
||||
|
||||
from ultralytics import __version__
|
||||
from ultralytics.utils.torch_utils import get_cpu_info, get_gpu_info
|
||||
|
||||
# Get RAM and disk totals
|
||||
memory = psutil.virtual_memory()
|
||||
disk_usage = shutil.disk_usage("/")
|
||||
|
||||
env = {
|
||||
"ultralyticsVersion": __version__,
|
||||
"hostname": socket.gethostname(),
|
||||
"os": platform.platform(),
|
||||
"environment": ENVIRONMENT,
|
||||
"pythonVersion": PYTHON_VERSION,
|
||||
"pythonExecutable": sys.executable,
|
||||
"cpuCount": os.cpu_count() or 0,
|
||||
"cpu": get_cpu_info(),
|
||||
"command": " ".join(sys.argv),
|
||||
"totalRamGb": round(memory.total / (1 << 30), 1), # Total RAM in GB
|
||||
"totalDiskGb": round(disk_usage.total / (1 << 30), 1), # Total disk in GB
|
||||
}
|
||||
|
||||
# Git info using cached GIT singleton (no subprocess calls)
|
||||
try:
|
||||
if GIT.is_repo:
|
||||
if GIT.origin:
|
||||
env["gitRepository"] = GIT.origin
|
||||
if GIT.branch:
|
||||
env["gitBranch"] = GIT.branch
|
||||
if GIT.commit:
|
||||
env["gitCommit"] = GIT.commit[:12] # Short hash
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# GPU info
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
env["gpuCount"] = torch.cuda.device_count()
|
||||
env["gpuType"] = get_gpu_info(0) if torch.cuda.device_count() > 0 else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def _get_project_name(trainer):
|
||||
"""Get slugified project and name from trainer args."""
|
||||
raw = str(trainer.args.project)
|
||||
parts = raw.split("/", 1)
|
||||
project = f"{parts[0]}/{slugify(parts[1])}" if len(parts) == 2 else slugify(raw)
|
||||
return project, slugify(str(trainer.args.name or "train"))
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer):
|
||||
"""Initialize Platform logging at training start."""
|
||||
if RANK not in {-1, 0} or not trainer.args.project:
|
||||
return
|
||||
|
||||
project, name = _get_project_name(trainer)
|
||||
LOGGER.info(f"{PREFIX}Streaming training metrics to Platform")
|
||||
|
||||
# Single dict for all platform callback state (like trainer.hub_session for HUB callbacks)
|
||||
ctx = {"model_id": None, "last_upload": time(), "cancelled": False, "console_logger": None, "system_logger": None}
|
||||
trainer.platform = ctx
|
||||
|
||||
# Create callback to send console output to Platform
|
||||
def send_console_output(content, line_count, chunk_id):
|
||||
"""Send batched console output to Platform webhook."""
|
||||
_send_async(
|
||||
"console_output",
|
||||
{"chunkId": chunk_id, "content": content, "lineCount": line_count},
|
||||
project,
|
||||
name,
|
||||
ctx["model_id"],
|
||||
)
|
||||
|
||||
# Start console capture with batching (5 lines or 5 seconds)
|
||||
ctx["console_logger"] = ConsoleLogger(batch_size=5, flush_interval=5.0, on_flush=send_console_output)
|
||||
ctx["console_logger"].start_capture()
|
||||
|
||||
# Collect environment info (W&B-style metadata)
|
||||
environment = _get_environment_info()
|
||||
|
||||
# Build trainArgs - callback runs before get_dataset() so args.data is still original (e.g., ul:// URIs)
|
||||
# Note: model_info is sent later in on_fit_epoch_end (epoch 0) when the model is actually loaded
|
||||
train_args = {k: str(v) for k, v in vars(trainer.args).items()}
|
||||
|
||||
# Send synchronously to get modelId for subsequent webhooks (critical, more retries)
|
||||
response = _send(
|
||||
"training_started",
|
||||
{
|
||||
"trainArgs": train_args,
|
||||
"epochs": trainer.epochs,
|
||||
"device": str(trainer.device),
|
||||
"environment": environment,
|
||||
},
|
||||
project,
|
||||
name,
|
||||
retry=4,
|
||||
)
|
||||
if response and response.get("modelId"):
|
||||
ctx["model_id"] = response["modelId"]
|
||||
# Server returns actual slug (may differ from requested name due to auto-increment, e.g. "train" → "train-2")
|
||||
if response.get("modelSlug"):
|
||||
ctx["model_slug"] = response["modelSlug"]
|
||||
url = f"{PLATFORM_URL}/{project}/{ctx['model_slug']}"
|
||||
LOGGER.info(f"{PREFIX}View model at {url}")
|
||||
# Check for immediate cancellation (cancelled before training started)
|
||||
# Note: trainer.stop is set in on_pretrain_routine_end (after _setup_train resets it)
|
||||
if response.get("cancelled"):
|
||||
ctx["cancelled"] = True
|
||||
else:
|
||||
LOGGER.warning(f"{PREFIX}Failed to register training session - metrics may not sync to Platform")
|
||||
|
||||
|
||||
def on_pretrain_routine_end(trainer):
|
||||
"""Apply pre-start cancellation after _setup_train resets trainer.stop."""
|
||||
ctx = getattr(trainer, "platform", None)
|
||||
if ctx and ctx["cancelled"]:
|
||||
LOGGER.info(f"{PREFIX}Training cancelled from Platform before starting ✅")
|
||||
trainer.stop = True
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
"""Log training and system metrics at epoch end."""
|
||||
ctx = getattr(trainer, "platform", None)
|
||||
if not ctx or RANK not in {-1, 0} or not trainer.args.project:
|
||||
return
|
||||
|
||||
project, name = _get_project_name(trainer)
|
||||
metrics = {**trainer.label_loss_items(trainer.tloss, prefix="train"), **trainer.metrics}
|
||||
|
||||
if trainer.optimizer and trainer.optimizer.param_groups:
|
||||
metrics["lr"] = trainer.optimizer.param_groups[0]["lr"]
|
||||
|
||||
# Extract model info at epoch 0 (sent as separate field, not in metrics)
|
||||
model_info = None
|
||||
if trainer.epoch == 0:
|
||||
try:
|
||||
info = model_info_for_loggers(trainer)
|
||||
model_info = {
|
||||
"parameters": info.get("model/parameters", 0),
|
||||
"gflops": info.get("model/GFLOPs", 0),
|
||||
"speedMs": info.get("model/speed_PyTorch(ms)", 0),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get system metrics (cache SystemLogger in platform context for efficiency)
|
||||
system = {}
|
||||
try:
|
||||
if not ctx["system_logger"]:
|
||||
ctx["system_logger"] = SystemLogger()
|
||||
system = ctx["system_logger"].get_metrics(rates=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {
|
||||
"epoch": trainer.epoch,
|
||||
"metrics": metrics,
|
||||
"system": system,
|
||||
"fitness": trainer.fitness,
|
||||
"best_fitness": trainer.best_fitness,
|
||||
}
|
||||
if model_info:
|
||||
payload["modelInfo"] = model_info
|
||||
|
||||
def _send_and_check_cancel():
|
||||
"""Send epoch_end and check response for cancellation (runs in background thread)."""
|
||||
response = _send("epoch_end", payload, project, name, ctx["model_id"], retry=1)
|
||||
if response and response.get("cancelled"):
|
||||
LOGGER.info(f"{PREFIX}Training cancelled from Platform ✅")
|
||||
trainer.stop = True
|
||||
ctx["cancelled"] = True
|
||||
|
||||
_executor.submit(_send_and_check_cancel)
|
||||
|
||||
|
||||
def on_model_save(trainer):
|
||||
"""Upload model checkpoint (rate limited to every 15 min)."""
|
||||
ctx = getattr(trainer, "platform", None)
|
||||
if not ctx or RANK not in {-1, 0} or not trainer.args.project:
|
||||
return
|
||||
|
||||
# Rate limit to every 15 minutes (900 seconds)
|
||||
if time() - ctx["last_upload"] < 900:
|
||||
return
|
||||
|
||||
model_path = trainer.best if trainer.best and Path(trainer.best).exists() else trainer.last
|
||||
if not model_path:
|
||||
return
|
||||
|
||||
project, name = _get_project_name(trainer)
|
||||
_upload_model_async(model_path, project, name, model_id=ctx["model_id"])
|
||||
ctx["last_upload"] = time()
|
||||
|
||||
|
||||
def on_train_end(trainer):
|
||||
"""Log final results, upload best model, and send validation plot data."""
|
||||
ctx = getattr(trainer, "platform", None)
|
||||
if not ctx or RANK not in {-1, 0} or not trainer.args.project:
|
||||
return
|
||||
|
||||
project, name = _get_project_name(trainer)
|
||||
|
||||
if ctx["cancelled"]:
|
||||
LOGGER.info(f"{PREFIX}Uploading partial results for cancelled training")
|
||||
|
||||
# Stop console capture
|
||||
if ctx["console_logger"]:
|
||||
ctx["console_logger"].stop_capture()
|
||||
ctx["console_logger"] = None
|
||||
|
||||
# Upload best model (blocking with progress bar to ensure it completes)
|
||||
gcs_path = None
|
||||
model_size = None
|
||||
if trainer.best and Path(trainer.best).exists():
|
||||
model_size = Path(trainer.best).stat().st_size
|
||||
gcs_path = _upload_model(trainer.best, project, name, progress=True, retry=3, model_id=ctx["model_id"])
|
||||
if not gcs_path:
|
||||
LOGGER.warning(f"{PREFIX}Model will not be available for download on Platform (upload failed)")
|
||||
|
||||
# Collect plots from trainer and validator, deduplicating by type
|
||||
plots_by_type = {}
|
||||
for info in getattr(trainer, "plots", {}).values():
|
||||
if info.get("data") and info["data"].get("type"):
|
||||
plots_by_type[info["data"]["type"]] = info["data"]
|
||||
for info in getattr(getattr(trainer, "validator", None), "plots", {}).values():
|
||||
if info.get("data") and info["data"].get("type"):
|
||||
plots_by_type.setdefault(info["data"]["type"], info["data"]) # Don't overwrite trainer plots
|
||||
plots = [_interp_plot(p) for p in plots_by_type.values()] # Interpolate curves to reduce size
|
||||
|
||||
# Get class names
|
||||
names = getattr(getattr(trainer, "validator", None), "names", None) or (trainer.data or {}).get("names")
|
||||
class_names = list(names.values()) if isinstance(names, dict) else list(names) if names else None
|
||||
|
||||
_send(
|
||||
"training_complete",
|
||||
{
|
||||
"results": {
|
||||
"metrics": {**trainer.metrics, "fitness": trainer.fitness},
|
||||
"bestEpoch": getattr(trainer, "best_epoch", trainer.epoch),
|
||||
"bestFitness": trainer.best_fitness,
|
||||
"modelPath": gcs_path, # Only send GCS path, not local path
|
||||
"modelSize": model_size,
|
||||
},
|
||||
"classNames": class_names,
|
||||
"plots": plots,
|
||||
},
|
||||
project,
|
||||
name,
|
||||
ctx["model_id"],
|
||||
retry=4, # Critical, more retries
|
||||
)
|
||||
url = f"{PLATFORM_URL}/{project}/{ctx.get('model_slug', name)}"
|
||||
LOGGER.info(f"{PREFIX}View results at {url}")
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_pretrain_routine_end": on_pretrain_routine_end,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_model_save": on_model_save,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if _api_key
|
||||
else {}
|
||||
)
|
||||
42
ultralytics/utils/callbacks/raytune.py
Executable file
42
ultralytics/utils/callbacks/raytune.py
Executable file
@@ -0,0 +1,42 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from ultralytics.utils import SETTINGS
|
||||
|
||||
try:
|
||||
assert SETTINGS["raytune"] is True # verify integration is enabled
|
||||
import ray
|
||||
from ray import tune
|
||||
from ray.air import session
|
||||
|
||||
except (ImportError, AssertionError):
|
||||
tune = None
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
"""Report training metrics to Ray Tune at epoch end when a Ray session is active.
|
||||
|
||||
Captures metrics from the trainer object and sends them to Ray Tune with the current epoch number, enabling
|
||||
hyperparameter tuning optimization. Only executes when within an active Ray Tune session.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The Ultralytics trainer object containing metrics and epochs.
|
||||
|
||||
Examples:
|
||||
>>> # Called automatically by the Ultralytics training loop
|
||||
>>> on_fit_epoch_end(trainer)
|
||||
|
||||
References:
|
||||
Ray Tune docs: https://docs.ray.io/en/latest/tune/index.html
|
||||
"""
|
||||
if ray.train._internal.session.get_session(): # check if Ray Tune session is active
|
||||
metrics = trainer.metrics
|
||||
session.report({**metrics, **{"epoch": trainer.epoch + 1}})
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
}
|
||||
if tune
|
||||
else {}
|
||||
)
|
||||
689
ultralytics/utils/callbacks/tensorboard.py
Executable file
689
ultralytics/utils/callbacks/tensorboard.py
Executable file
@@ -0,0 +1,689 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
import warnings
|
||||
|
||||
from ultralytics.utils import DEFAULT_CFG, LOGGER, RANK, SETTINGS, TESTS_RUNNING, colorstr, torch_utils
|
||||
from ultralytics.utils.torch_utils import smart_inference_mode
|
||||
|
||||
try:
|
||||
assert not TESTS_RUNNING # do not log pytest
|
||||
assert SETTINGS["tensorboard"] is True # verify integration is enabled
|
||||
WRITER = None # TensorBoard SummaryWriter instance
|
||||
PREFIX = colorstr("TensorBoard: ")
|
||||
|
||||
# Imports below only required if TensorBoard enabled
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
|
||||
except (ImportError, AssertionError, TypeError, AttributeError):
|
||||
# TypeError for handling 'Descriptors cannot not be created directly.' protobuf errors in Windows
|
||||
# AttributeError: module 'tensorflow' has no attribute 'io' if 'tensorflow' not installed
|
||||
SummaryWriter = None
|
||||
|
||||
# Global state for validation logging
|
||||
_val_sample_indices_to_log = set()
|
||||
_val_seen_samples = 0
|
||||
_val_index_offset = 0
|
||||
_current_epoch = 0
|
||||
|
||||
|
||||
def _reset_val_logging_state() -> None:
|
||||
"""Reset validation image logging state."""
|
||||
global _val_sample_indices_to_log, _val_seen_samples, _val_index_offset
|
||||
_val_sample_indices_to_log = set()
|
||||
_val_seen_samples = 0
|
||||
_val_index_offset = 0
|
||||
|
||||
|
||||
def _close_writer() -> None:
|
||||
"""Flush and close the active TensorBoard writer if one exists."""
|
||||
global WRITER
|
||||
if WRITER:
|
||||
try:
|
||||
WRITER.flush()
|
||||
finally:
|
||||
WRITER.close()
|
||||
WRITER = None
|
||||
|
||||
|
||||
def _sanitize_tb_tag_component(value: str, max_len: int = 80) -> str:
|
||||
"""Return a TensorBoard-friendly tag component."""
|
||||
value = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in str(value).strip())
|
||||
value = value.strip("._")
|
||||
return value[:max_len] if value else "unknown"
|
||||
|
||||
|
||||
def _create_detection_image(images, targets, paths=None, names=None, is_labels=True, conf_thres=0.25):
|
||||
"""Create detection visualization image for TensorBoard logging.
|
||||
|
||||
Args:
|
||||
images (torch.Tensor): Batch of images (B, C, H, W)
|
||||
targets (torch.Tensor | np.ndarray): Target labels or predictions
|
||||
- For labels: (N, 6) [batch_idx, class_id, x, y, w, h] (normalized xywh)
|
||||
- For predictions: (N, 7) [batch_idx, class_id, x, y, w, h, conf] (normalized xywh)
|
||||
paths (list): Image paths
|
||||
names (dict | list): Class names
|
||||
is_labels (bool): Whether targets are ground truth labels (True) or predictions (False)
|
||||
conf_thres (float): Confidence threshold for filtering predictions
|
||||
|
||||
Returns:
|
||||
np.ndarray: RGB image with bounding boxes drawn
|
||||
"""
|
||||
if not WRITER:
|
||||
return None
|
||||
|
||||
try:
|
||||
from ultralytics.utils.plotting import Annotator, colors
|
||||
from ultralytics.utils.ops import xywh2xyxy
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
# Convert to numpy
|
||||
if isinstance(images, torch.Tensor):
|
||||
images = images.cpu().float().numpy()
|
||||
if isinstance(targets, torch.Tensor):
|
||||
targets = targets.cpu().numpy()
|
||||
|
||||
max_size = 1920 # max image size
|
||||
max_subplots = 1 # limit to 1 image for cleaner visualization
|
||||
bs, _, h, w = images.shape # batch size, _, height, width
|
||||
bs = min(bs, max_subplots)
|
||||
ns = np.ceil(bs**0.5) # number of subplots (square)
|
||||
|
||||
if np.max(images[0]) <= 1:
|
||||
images = images * 255 # de-normalise
|
||||
|
||||
# Build Image
|
||||
mosaic = np.full((int(ns * h), int(ns * w), 3), 255, dtype=np.uint8)
|
||||
for i, im in enumerate(images):
|
||||
if i == max_subplots:
|
||||
break
|
||||
x, y = int(w * (i // ns)), int(h * (i % ns)) # block origin
|
||||
im = im.transpose(1, 2, 0) # CHW to HWC
|
||||
# Images are in RGB format (converted from BGR in augmentation pipeline with default bgr=0.0)
|
||||
# No color conversion needed for TensorBoard
|
||||
mosaic[y : y + h, x : x + w, :] = im.astype(np.uint8)
|
||||
|
||||
# Resize (optional)
|
||||
scale = max_size / ns / max(h, w)
|
||||
if scale < 1:
|
||||
h = math.ceil(scale * h)
|
||||
w = math.ceil(scale * w)
|
||||
mosaic = cv2.resize(mosaic, tuple(int(x * ns) for x in (w, h)), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# Annotate with thin lines
|
||||
fs = int((h + w) * ns * 0.01) # font size
|
||||
line_width = max(1, round(fs / 30)) # thin line width
|
||||
annotator = Annotator(mosaic, line_width=line_width, font_size=fs, pil=True)
|
||||
|
||||
for i in range(bs):
|
||||
x, y = int(w * (i // ns)), int(h * (i % ns)) # block origin
|
||||
annotator.rectangle([x, y, x + w, y + h], None, (255, 255, 255), width=2) # borders
|
||||
if paths:
|
||||
annotator.text([x + 5, y + 5], text=Path(paths[i]).name[:40], txt_color=(220, 220, 220))
|
||||
|
||||
if len(targets) > 0:
|
||||
ti = targets[targets[:, 0] == i] # image targets
|
||||
boxes = xywh2xyxy(ti[:, 2:6]).T
|
||||
classes = ti[:, 1].astype("int")
|
||||
conf = None if is_labels else ti[:, 6] # confidence only for predictions
|
||||
|
||||
if boxes.shape[1]:
|
||||
if boxes.max() <= 1.01: # if normalized with tolerance 0.01
|
||||
boxes[[0, 2]] *= w # scale to pixels
|
||||
boxes[[1, 3]] *= h
|
||||
elif scale < 1: # absolute coords need scale if image scales
|
||||
boxes *= scale
|
||||
boxes[[0, 2]] += x
|
||||
boxes[[1, 3]] += y
|
||||
|
||||
for j, box in enumerate(boxes.T.tolist()):
|
||||
cls = classes[j]
|
||||
color = colors(cls)
|
||||
|
||||
# Get class name
|
||||
if names:
|
||||
if isinstance(names, dict):
|
||||
cls_name = names.get(cls, str(cls))
|
||||
elif isinstance(names, list):
|
||||
cls_name = names[cls] if cls < len(names) else str(cls)
|
||||
else:
|
||||
cls_name = str(cls)
|
||||
else:
|
||||
cls_name = str(cls)
|
||||
|
||||
# For predictions, show confidence score; for labels, show class name
|
||||
if is_labels or (conf is not None and conf[j] > conf_thres):
|
||||
if not is_labels and conf is not None:
|
||||
label = f"{cls_name} {conf[j]:.2f}" # Show class and confidence for predictions
|
||||
else:
|
||||
label = cls_name # Show class name for ground truth
|
||||
annotator.box_label(box, label, color=color)
|
||||
|
||||
# Get result from Annotator (returns RGB when pil=True)
|
||||
result = np.asarray(annotator.result())
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{PREFIX}Failed to create detection image: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _output_to_target(output, max_det=300):
|
||||
"""Convert model output to target format for plotting.
|
||||
|
||||
Args:
|
||||
output (list): List of prediction dicts or tensors
|
||||
- For dict format: [{'bboxes': (N,4), 'conf': (N,), 'cls': (N,), ...}, ...]
|
||||
- For tensor format: [(N, 6) [x1, y1, x2, y2, conf, cls], ...]
|
||||
max_det (int): Maximum detections per image
|
||||
|
||||
Returns:
|
||||
np.ndarray: (M, 7) [batch_idx, class_id, x, y, w, h, conf] (normalized xywh)
|
||||
"""
|
||||
try:
|
||||
from ultralytics.utils.ops import xyxy2xywh
|
||||
|
||||
targets = []
|
||||
for i, o in enumerate(output):
|
||||
# Handle dict format (new postprocess output)
|
||||
if isinstance(o, dict):
|
||||
bboxes = o.get('bboxes', torch.empty((0, 4)))
|
||||
conf = o.get('conf', torch.empty((0,)))
|
||||
cls = o.get('cls', torch.empty((0,)))
|
||||
|
||||
if len(bboxes) == 0:
|
||||
continue
|
||||
|
||||
# Limit detections
|
||||
if len(bboxes) > max_det:
|
||||
bboxes = bboxes[:max_det]
|
||||
conf = conf[:max_det]
|
||||
cls = cls[:max_det]
|
||||
|
||||
# Ensure proper shapes
|
||||
if conf.dim() == 1:
|
||||
conf = conf.unsqueeze(-1)
|
||||
if cls.dim() == 1:
|
||||
cls = cls.unsqueeze(-1)
|
||||
|
||||
# Create batch index
|
||||
j = torch.full((len(bboxes), 1), i, device=bboxes.device)
|
||||
|
||||
# Concatenate: [batch_idx, cls, xywh, conf]
|
||||
targets.append(torch.cat((j, cls, xyxy2xywh(bboxes), conf), 1))
|
||||
|
||||
# Handle tensor format (legacy)
|
||||
else:
|
||||
if len(o) == 0:
|
||||
continue
|
||||
o = o[:max_det] # limit detections
|
||||
box, conf, cls = o[:, :4], o[:, 4:5], o[:, 5:6]
|
||||
j = torch.full((conf.shape[0], 1), i, device=o.device) # batch index
|
||||
targets.append(torch.cat((j, cls, xyxy2xywh(box), conf), 1))
|
||||
|
||||
if targets:
|
||||
return torch.cat(targets, 0).cpu().numpy()
|
||||
else:
|
||||
return np.empty((0, 7))
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{PREFIX}Failed to convert output to target: {e}")
|
||||
return np.empty((0, 7))
|
||||
|
||||
|
||||
def _log_scalars(scalars: dict | None, step: int = 0) -> None:
|
||||
"""Log scalar values to TensorBoard.
|
||||
|
||||
Args:
|
||||
scalars (dict | None): Dictionary of scalar values to log to TensorBoard. Keys are scalar names and values are
|
||||
the corresponding scalar values. If None or empty, nothing is logged.
|
||||
step (int): Global step value to record with the scalar values. Used for x-axis in TensorBoard graphs.
|
||||
|
||||
Examples:
|
||||
Log training metrics
|
||||
>>> metrics = {"loss": 0.5, "accuracy": 0.95}
|
||||
>>> _log_scalars(metrics, step=100)
|
||||
"""
|
||||
if WRITER and scalars:
|
||||
for k, v in scalars.items():
|
||||
WRITER.add_scalar(k, v, step)
|
||||
|
||||
|
||||
@smart_inference_mode()
|
||||
def _log_tensorboard_graph(trainer) -> None:
|
||||
"""Log model graph to TensorBoard.
|
||||
|
||||
This function attempts to visualize the model architecture in TensorBoard by tracing the model with a dummy input
|
||||
tensor. It first tries a simple method suitable for YOLO models, and if that fails, falls back to a more complex
|
||||
approach for models like RTDETR that may require special handling.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The trainer object containing the model to visualize. Must
|
||||
have attributes model and args with imgsz.
|
||||
|
||||
Notes:
|
||||
This function requires TensorBoard integration to be enabled and the global WRITER to be initialized.
|
||||
It handles potential warnings from the PyTorch JIT tracer and attempts to gracefully handle different
|
||||
model architectures.
|
||||
"""
|
||||
# Input image - use CPU to avoid corrupting CUDA context if tracing fails
|
||||
imgsz = trainer.args.imgsz
|
||||
imgsz = (imgsz, imgsz) if isinstance(imgsz, int) else imgsz
|
||||
im = torch.zeros((1, 3, *imgsz), device="cpu", dtype=torch.float32)
|
||||
|
||||
# Try simple method first (YOLO)
|
||||
try:
|
||||
model = deepcopy(torch_utils.unwrap_model(trainer.model)).cpu().eval()
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message="The input to trace is already a ScriptModule, tracing it is a no-op.*",
|
||||
category=UserWarning,
|
||||
)
|
||||
WRITER.add_graph(torch.jit.trace(model, im, strict=False), [])
|
||||
LOGGER.info(f"{PREFIX}model graph visualization added ✅")
|
||||
return
|
||||
except Exception as e1:
|
||||
# Fallback to TorchScript export steps (RTDETR)
|
||||
try:
|
||||
model = deepcopy(torch_utils.unwrap_model(trainer.model)).cpu().eval()
|
||||
model = model.fuse(verbose=False)
|
||||
for m in model.modules():
|
||||
if hasattr(m, "export"): # Detect, RTDETRDecoder (Segment and Pose use Detect base class)
|
||||
m.export = True
|
||||
m.format = "torchscript"
|
||||
model(im) # dry run
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message="The input to trace is already a ScriptModule, tracing it is a no-op.*",
|
||||
category=UserWarning,
|
||||
)
|
||||
WRITER.add_graph(torch.jit.trace(model, im, strict=False), [])
|
||||
LOGGER.info(f"{PREFIX}model graph visualization added ✅")
|
||||
except Exception as e2:
|
||||
LOGGER.warning(f"{PREFIX}TensorBoard graph visualization failure: {e1} -> {e2}")
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer) -> None:
|
||||
"""Initialize TensorBoard logging with SummaryWriter."""
|
||||
if SummaryWriter:
|
||||
try:
|
||||
global WRITER
|
||||
WRITER = SummaryWriter(str(trainer.save_dir))
|
||||
LOGGER.info(f"{PREFIX}Start with 'tensorboard --logdir {trainer.save_dir}', view at http://localhost:6006/")
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{PREFIX}TensorBoard not initialized correctly, not logging this run. {e}")
|
||||
|
||||
|
||||
def on_train_start(trainer) -> None:
|
||||
"""Log TensorBoard graph."""
|
||||
if WRITER:
|
||||
_log_tensorboard_graph(trainer)
|
||||
|
||||
|
||||
def on_train_epoch_end(trainer) -> None:
|
||||
"""Log scalar statistics at the end of a training epoch."""
|
||||
_log_scalars(trainer.label_loss_items(trainer.tloss, prefix="train"), trainer.epoch + 1)
|
||||
_log_scalars(trainer.lr, trainer.epoch + 1)
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer) -> None:
|
||||
"""Log epoch metrics at end of training epoch."""
|
||||
_log_scalars(trainer.metrics, trainer.epoch + 1)
|
||||
|
||||
|
||||
def on_val_start(validator) -> None:
|
||||
"""Initialize validation logging - select samples to log."""
|
||||
global _val_sample_indices_to_log, _val_seen_samples, _val_index_offset
|
||||
if WRITER:
|
||||
dataset = getattr(validator.dataloader, "dataset", None)
|
||||
total_samples = len(dataset or [])
|
||||
if total_samples <= 0:
|
||||
total_samples = len(validator.dataloader)
|
||||
num_samples = min(50, total_samples)
|
||||
sample_indices = np.linspace(0, max(total_samples - 1, 0), num_samples, dtype=int).tolist()
|
||||
|
||||
sampler = getattr(validator.dataloader, "sampler", None)
|
||||
if sampler and hasattr(sampler, "_get_rank_indices"):
|
||||
start_idx, end_idx = sampler._get_rank_indices()
|
||||
_val_index_offset = start_idx
|
||||
_val_sample_indices_to_log = {idx - start_idx for idx in sample_indices if start_idx <= idx < end_idx}
|
||||
else:
|
||||
_val_index_offset = 0
|
||||
_val_sample_indices_to_log = set(sample_indices)
|
||||
|
||||
_val_seen_samples = 0
|
||||
|
||||
|
||||
def on_val_batch_end(validator) -> None:
|
||||
"""Log validation sample images to TensorBoard as 2x2 grid if 3D data available."""
|
||||
global _current_epoch, _val_sample_indices_to_log, _val_seen_samples, _val_index_offset
|
||||
|
||||
if not WRITER or not _val_sample_indices_to_log:
|
||||
return
|
||||
|
||||
try:
|
||||
batch = validator.batch
|
||||
batch_size = len(batch.get("im_file", [])) or int(batch["img"].shape[0])
|
||||
batch_start = _val_seen_samples
|
||||
batch_end = batch_start + batch_size
|
||||
sample_indices = [i for i in range(batch_start, batch_end) if i in _val_sample_indices_to_log]
|
||||
_val_seen_samples = batch_end
|
||||
if not sample_indices:
|
||||
return
|
||||
|
||||
# 2x2 grid [Target 2D | Target 3D] / [Pred 2D | Pred 3D]
|
||||
for sample_idx in sample_indices:
|
||||
batch_sample_idx = sample_idx - batch_start
|
||||
_log_3d_visualization(validator, batch, batch_sample_idx, sample_idx + _val_index_offset)
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{PREFIX}Failed to log validation images: {e}")
|
||||
|
||||
|
||||
def _draw_dashed_rectangle(img, p1, p2, color, thickness=1, dash=8, gap=5):
|
||||
"""Draw a dashed rectangle on an image."""
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
for x in range(x1, x2, dash + gap):
|
||||
cv2.line(img, (x, y1), (min(x + dash, x2), y1), color, thickness, cv2.LINE_AA)
|
||||
cv2.line(img, (x, y2), (min(x + dash, x2), y2), color, thickness, cv2.LINE_AA)
|
||||
for y in range(y1, y2, dash + gap):
|
||||
cv2.line(img, (x1, y), (x1, min(y + dash, y2)), color, thickness, cv2.LINE_AA)
|
||||
cv2.line(img, (x2, y), (x2, min(y + dash, y2)), color, thickness, cv2.LINE_AA)
|
||||
|
||||
|
||||
def _draw_2d_boxes_on_image(img, boxes_xyxy, cls_ids, names=None, confs=None, thickness=1, diff_cls=None):
|
||||
"""Draw 2D bounding boxes on an image using cv2 (BGR color conventions).
|
||||
|
||||
Args:
|
||||
img: (H, W, 3) uint8 image (BGR).
|
||||
boxes_xyxy: (N, 4) array of [x1, y1, x2, y2] in pixel coords.
|
||||
cls_ids: (N,) array of class IDs.
|
||||
names: Dict or list of class names.
|
||||
confs: (N,) array of confidences, or None for GT.
|
||||
thickness: Line thickness.
|
||||
"""
|
||||
from ultralytics.utils.plotting import colors as get_color
|
||||
|
||||
for i in range(len(boxes_xyxy)):
|
||||
x1, y1, x2, y2 = [int(v) for v in boxes_xyxy[i]]
|
||||
cls = int(cls_ids[i])
|
||||
color = get_color(cls, bgr=True)
|
||||
is_diff1 = diff_cls is not None and int(diff_cls[i]) == 1
|
||||
if is_diff1:
|
||||
_draw_dashed_rectangle(img, (x1, y1), (x2, y2), color, thickness=max(thickness, 1))
|
||||
else:
|
||||
cv2.rectangle(img, (x1, y1), (x2, y2), color, thickness, cv2.LINE_AA)
|
||||
|
||||
# Label
|
||||
if names:
|
||||
cls_name = names.get(cls, str(cls)) if isinstance(names, dict) else (
|
||||
names[cls] if isinstance(names, list) and cls < len(names) else str(cls))
|
||||
else:
|
||||
cls_name = str(cls)
|
||||
label = f"{cls_name} {confs[i]:.2f}" if confs is not None else cls_name
|
||||
if diff_cls is not None:
|
||||
label = f"{label} d{int(diff_cls[i])}"
|
||||
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
|
||||
cv2.rectangle(img, (x1, y1 - th - 4), (x1 + tw, y1), color, -1)
|
||||
cv2.putText(img, label, (x1, y1 - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINE_AA)
|
||||
|
||||
|
||||
def _log_3d_visualization(validator, batch, batch_sample_idx=0, sample_idx=None):
|
||||
"""Log 2x2 grid visualization: [Target 2D | Target 3D] / [Pred 2D | Pred 3D].
|
||||
|
||||
GT depth is restored in decode_3d_target() using calib["depth_scale"].
|
||||
Prediction depth is restored once upstream in Ground3DDetectionValidator.postprocess().
|
||||
Both are projected with the original batch calibration.
|
||||
"""
|
||||
labels_3d = batch.get("labels_3d")
|
||||
if labels_3d is None or len(labels_3d) == 0:
|
||||
return
|
||||
|
||||
try:
|
||||
from ultralytics.utils.ops import xywh2xyxy
|
||||
from ultralytics.utils.plotting_3d import (
|
||||
collect_precomputed_edge_points_2d,
|
||||
decode_3d_prediction,
|
||||
decode_3d_target,
|
||||
draw_3d_box,
|
||||
)
|
||||
|
||||
# Get 3D class config from model
|
||||
model = validator.trainer.model if hasattr(validator, "trainer") and validator.trainer else None
|
||||
if model is None:
|
||||
return
|
||||
if hasattr(model, "module"):
|
||||
model = model.module
|
||||
face_3d_classes = getattr(model, "face_3d_classes", set())
|
||||
complete_3d_classes = getattr(model, "complete_3d_classes", set())
|
||||
if not face_3d_classes and not complete_3d_classes:
|
||||
return
|
||||
|
||||
images = batch["img"]
|
||||
batch_idx_t = batch.get("batch_idx")
|
||||
batch_cls_t = batch.get("cls")
|
||||
batch_bboxes = batch.get("bboxes")
|
||||
if batch_idx_t is None or batch_cls_t is None:
|
||||
return
|
||||
|
||||
batch_idx_np = batch_idx_t.cpu().numpy().ravel()
|
||||
cls_np = batch_cls_t.cpu().numpy().ravel()
|
||||
labels_3d_np = labels_3d.cpu().numpy()
|
||||
edge_faces_points_2d = batch.get("edge_faces_points_2d")
|
||||
edge_faces_valid = batch.get("edge_faces_valid")
|
||||
edge_faces_points_2d_np = edge_faces_points_2d.cpu().numpy() if edge_faces_points_2d is not None else None
|
||||
edge_faces_valid_np = edge_faces_valid.cpu().numpy() if edge_faces_valid is not None else None
|
||||
face_visibility_score_thresh = float(
|
||||
getattr(validator.args, "face_visibility_score_thresh", DEFAULT_CFG.face_visibility_score_thresh)
|
||||
)
|
||||
_, _, img_h, img_w = images.shape
|
||||
|
||||
batch_calib = batch.get("calib")
|
||||
calib = batch_calib[batch_sample_idx] if batch_calib is not None and batch_sample_idx < len(batch_calib) else None
|
||||
if calib is None:
|
||||
LOGGER.warning(f"{PREFIX}No calib in batch, skipping 3D visualization")
|
||||
return
|
||||
|
||||
batch_camera_mode = batch.get("camera_mode")
|
||||
camera_mode = (
|
||||
str(batch_camera_mode[batch_sample_idx])
|
||||
if isinstance(batch_camera_mode, (list, tuple)) and batch_sample_idx < len(batch_camera_mode)
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
epoch = validator.trainer.epoch if validator.training and validator.trainer else 0
|
||||
global_sample_idx = sample_idx if sample_idx is not None else batch_sample_idx
|
||||
rank_tag = f"rank{RANK if RANK >= 0 else 0}"
|
||||
batch_im_files = batch.get("im_file")
|
||||
image_name = None
|
||||
if isinstance(batch_im_files, (list, tuple)) and batch_sample_idx < len(batch_im_files):
|
||||
image_name = Path(str(batch_im_files[batch_sample_idx])).name
|
||||
image_tag = _sanitize_tb_tag_component(Path(image_name).stem if image_name else "unknown")
|
||||
|
||||
# --- Prepare base image ---
|
||||
im0 = images[batch_sample_idx].cpu().numpy().transpose(1, 2, 0)
|
||||
im0 = np.ascontiguousarray(im0 * 255, dtype=np.uint8)
|
||||
im0 = cv2.cvtColor(im0, cv2.COLOR_RGB2BGR)
|
||||
|
||||
# Create 4 copies for the 2x2 grid
|
||||
im_gt_2d = im0.copy()
|
||||
im_gt_3d = im0.copy()
|
||||
im_pred_2d = im0.copy()
|
||||
im_pred_3d = im0.copy()
|
||||
|
||||
line_thick = 1
|
||||
|
||||
# --- GT: Decode and draw boxes for selected image ---
|
||||
mask_i = batch_idx_np == batch_sample_idx
|
||||
if batch_bboxes is not None and mask_i.any():
|
||||
gt_bboxes_xywh = batch_bboxes[mask_i].cpu().numpy()
|
||||
gt_cls = cls_np[mask_i]
|
||||
gt_bboxes_xyxy = xywh2xyxy(torch.from_numpy(gt_bboxes_xywh)).numpy()
|
||||
gt_bboxes_xyxy[:, [0, 2]] *= img_w
|
||||
gt_bboxes_xyxy[:, [1, 3]] *= img_h
|
||||
_draw_2d_boxes_on_image(im_gt_2d, gt_bboxes_xyxy, gt_cls, names=validator.names, thickness=line_thick)
|
||||
|
||||
gt_indices = np.where(mask_i)[0]
|
||||
for local_idx, idx in enumerate(gt_indices):
|
||||
bbox_xyxy = gt_bboxes_xyxy[local_idx] if batch_bboxes is not None and mask_i.any() else None
|
||||
d = decode_3d_target(
|
||||
labels_3d_np[idx],
|
||||
int(cls_np[idx]),
|
||||
calib,
|
||||
img_w,
|
||||
img_h,
|
||||
face_3d_classes,
|
||||
complete_3d_classes,
|
||||
score_thr=face_visibility_score_thresh,
|
||||
bbox_xyxy=bbox_xyxy,
|
||||
)
|
||||
if d is not None and d.get("corners_3d") is not None:
|
||||
if edge_faces_points_2d_np is not None and edge_faces_valid_np is not None and idx < len(edge_faces_points_2d_np):
|
||||
edge_points_2d = collect_precomputed_edge_points_2d(
|
||||
edge_faces_points_2d_np[idx],
|
||||
edge_faces_valid_np[idx],
|
||||
visible_face_types=d.get("visible_face_types", ()),
|
||||
)
|
||||
if edge_points_2d is not None:
|
||||
d = {**d, "edge_points_2d": edge_points_2d}
|
||||
draw_3d_box(
|
||||
im_gt_3d,
|
||||
d["corners_3d"],
|
||||
calib,
|
||||
d.get("face_center_2d"),
|
||||
d.get("face_color"),
|
||||
edge_points_2d=d.get("edge_points_2d"),
|
||||
edge_color=(0, 255, 0),
|
||||
thickness=line_thick,
|
||||
)
|
||||
|
||||
# --- Predictions: Decode and draw boxes for selected image ---
|
||||
preds_3d_sel = getattr(validator, "_preds_3d_selected", None)
|
||||
preds_edge_sel = getattr(validator, "_preds_edge_selected", None)
|
||||
preds_diff_sel = getattr(validator, "_preds_diff_selected", None)
|
||||
anchors_sel = getattr(validator, "_anchors_selected", None)
|
||||
strides_sel = getattr(validator, "_strides_selected", None)
|
||||
preds_2d = validator.pred
|
||||
|
||||
if preds_2d is not None and len(preds_2d) > batch_sample_idx:
|
||||
pred_i = preds_2d[batch_sample_idx]
|
||||
pred_bboxes = pred_i["bboxes"].cpu().numpy()
|
||||
pred_cls_np = pred_i["cls"].cpu().numpy()
|
||||
pred_conf_np = pred_i["conf"].cpu().numpy()
|
||||
display_conf = max(float(getattr(validator.args, "visualize_conf", 0.25)), float(getattr(validator.args, "conf", 0.0)))
|
||||
display_mask = pred_conf_np >= display_conf
|
||||
|
||||
if display_mask.any():
|
||||
pred_diff_np = None
|
||||
if preds_diff_sel is not None:
|
||||
diff_logits = preds_diff_sel[batch_sample_idx]
|
||||
if diff_logits.shape[-1] == 1:
|
||||
diff_prob = diff_logits[..., 0].sigmoid()
|
||||
diff_thres = float(getattr(validator.args, "diff_thres", 0.7))
|
||||
pred_diff_np = (diff_prob >= diff_thres).cpu().numpy().astype(np.int64)
|
||||
elif diff_logits.shape[-1] == 2:
|
||||
pred_diff_np = diff_logits.softmax(-1).argmax(-1).cpu().numpy().astype(np.int64)
|
||||
_draw_2d_boxes_on_image(
|
||||
im_pred_2d,
|
||||
pred_bboxes[display_mask],
|
||||
pred_cls_np[display_mask],
|
||||
names=validator.names,
|
||||
confs=pred_conf_np[display_mask],
|
||||
thickness=line_thick,
|
||||
diff_cls=pred_diff_np[display_mask] if pred_diff_np is not None else None,
|
||||
)
|
||||
|
||||
if preds_3d_sel is not None and anchors_sel is not None and strides_sel is not None:
|
||||
p3d = preds_3d_sel[batch_sample_idx].cpu().numpy()
|
||||
pedge = preds_edge_sel[batch_sample_idx].cpu().numpy() if preds_edge_sel is not None else None
|
||||
anchors_np = anchors_sel[batch_sample_idx].cpu().numpy()
|
||||
strides_np = strides_sel[batch_sample_idx].cpu().numpy()
|
||||
for i in np.where(display_mask)[0]:
|
||||
d = decode_3d_prediction(
|
||||
p3d[i],
|
||||
anchors_np[:, i],
|
||||
float(strides_np[i]),
|
||||
calib,
|
||||
img_w,
|
||||
img_h,
|
||||
face_3d_classes,
|
||||
complete_3d_classes,
|
||||
int(pred_cls_np[i]),
|
||||
pred_edge_60=pedge[i] if pedge is not None else None,
|
||||
bbox_xyxy=pred_bboxes[i],
|
||||
)
|
||||
if d is not None and d.get("corners_3d") is not None:
|
||||
draw_3d_box(
|
||||
im_pred_3d,
|
||||
d["corners_3d"],
|
||||
calib,
|
||||
d.get("face_center_2d"),
|
||||
d.get("face_color"),
|
||||
edge_points_2d=d.get("edge_points_2d"),
|
||||
edge_color=(0, 165, 255),
|
||||
thickness=line_thick,
|
||||
)
|
||||
|
||||
# --- Add labels to each quadrant ---
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
for im_q, label in [(im_gt_2d, "Target 2D"), (im_gt_3d, "Target 3D"),
|
||||
(im_pred_2d, "Pred 2D"), (im_pred_3d, "Pred 3D")]:
|
||||
cv2.putText(im_q, label, (10, 40), font, 1.0, (0, 255, 255), 1, cv2.LINE_AA)
|
||||
|
||||
# --- Assemble 2x2 grid ---
|
||||
top_row = np.hstack([im_gt_2d, im_gt_3d])
|
||||
bot_row = np.hstack([im_pred_2d, im_pred_3d])
|
||||
grid = np.vstack([top_row, bot_row])
|
||||
|
||||
header = f"{image_name or 'unknown'} | idx={global_sample_idx:04d} | {rank_tag} | mode={camera_mode}"
|
||||
cv2.rectangle(grid, (0, 0), (grid.shape[1], 30), (32, 32, 32), -1)
|
||||
cv2.putText(grid, header[:200], (10, 21), font, 0.55, (255, 255, 255), 1, cv2.LINE_AA)
|
||||
|
||||
# Convert BGR -> RGB for TensorBoard
|
||||
grid_rgb = cv2.cvtColor(grid, cv2.COLOR_BGR2RGB)
|
||||
|
||||
WRITER.add_image(
|
||||
f"val/{rank_tag}/{camera_mode}/sample{global_sample_idx:04d}_{image_tag}_2d3d",
|
||||
grid_rgb,
|
||||
dataformats="HWC",
|
||||
global_step=epoch,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{PREFIX}Failed to log 3D visualization: {e}")
|
||||
|
||||
|
||||
def on_val_end(validator) -> None:
|
||||
"""Clean up after validation."""
|
||||
_reset_val_logging_state()
|
||||
|
||||
|
||||
def on_train_end(trainer) -> None:
|
||||
"""Flush and close the TensorBoard writer at the end of training."""
|
||||
_reset_val_logging_state()
|
||||
_close_writer()
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_train_start": on_train_start,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_train_epoch_end": on_train_epoch_end,
|
||||
"on_val_start": on_val_start,
|
||||
"on_val_batch_end": on_val_batch_end,
|
||||
"on_val_end": on_val_end,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if SummaryWriter
|
||||
else {}
|
||||
)
|
||||
193
ultralytics/utils/callbacks/wb.py
Executable file
193
ultralytics/utils/callbacks/wb.py
Executable file
@@ -0,0 +1,193 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from ultralytics.utils import SETTINGS, TESTS_RUNNING
|
||||
from ultralytics.utils.torch_utils import model_info_for_loggers
|
||||
|
||||
try:
|
||||
assert not TESTS_RUNNING # do not log pytest
|
||||
assert SETTINGS["wandb"] is True # verify integration is enabled
|
||||
import wandb as wb
|
||||
|
||||
assert hasattr(wb, "__version__") # verify package is not directory
|
||||
_processed_plots = {}
|
||||
|
||||
except (ImportError, AssertionError):
|
||||
wb = None
|
||||
|
||||
|
||||
def _custom_table(x, y, classes, title="Precision Recall Curve", x_title="Recall", y_title="Precision"):
|
||||
"""Create and log a custom metric visualization table.
|
||||
|
||||
This function crafts a custom metric visualization that mimics the behavior of the default wandb precision-recall
|
||||
curve while allowing for enhanced customization. The visual metric is useful for monitoring model performance across
|
||||
different classes.
|
||||
|
||||
Args:
|
||||
x (list): Values for the x-axis; expected to have length N.
|
||||
y (list): Corresponding values for the y-axis; also expected to have length N.
|
||||
classes (list): Labels identifying the class of each point; length N.
|
||||
title (str, optional): Title for the plot.
|
||||
x_title (str, optional): Label for the x-axis.
|
||||
y_title (str, optional): Label for the y-axis.
|
||||
|
||||
Returns:
|
||||
(wandb.Object): A wandb object suitable for logging, showcasing the crafted metric visualization.
|
||||
"""
|
||||
import polars as pl # scope for faster 'import ultralytics'
|
||||
import polars.selectors as cs
|
||||
|
||||
df = pl.DataFrame({"class": classes, "y": y, "x": x}).with_columns(cs.numeric().round(3))
|
||||
data = df.select(["class", "y", "x"]).rows()
|
||||
|
||||
fields = {"x": "x", "y": "y", "class": "class"}
|
||||
string_fields = {"title": title, "x-axis-title": x_title, "y-axis-title": y_title}
|
||||
return wb.plot_table(
|
||||
"wandb/area-under-curve/v0",
|
||||
wb.Table(data=data, columns=["class", "y", "x"]),
|
||||
fields=fields,
|
||||
string_fields=string_fields,
|
||||
)
|
||||
|
||||
|
||||
def _plot_curve(
|
||||
x,
|
||||
y,
|
||||
names=None,
|
||||
id="precision-recall",
|
||||
title="Precision Recall Curve",
|
||||
x_title="Recall",
|
||||
y_title="Precision",
|
||||
num_x=100,
|
||||
only_mean=False,
|
||||
):
|
||||
"""Log a metric curve visualization.
|
||||
|
||||
This function generates a metric curve based on input data and logs the visualization to wandb. The curve can
|
||||
represent aggregated data (mean) or individual class data, depending on the 'only_mean' flag.
|
||||
|
||||
Args:
|
||||
x (np.ndarray): Data points for the x-axis with length N.
|
||||
y (np.ndarray): Corresponding data points for the y-axis with shape (C, N), where C is the number of classes.
|
||||
names (list, optional): Names of the classes corresponding to the y-axis data; length C.
|
||||
id (str, optional): Unique identifier for the logged data in wandb.
|
||||
title (str, optional): Title for the visualization plot.
|
||||
x_title (str, optional): Label for the x-axis.
|
||||
y_title (str, optional): Label for the y-axis.
|
||||
num_x (int, optional): Number of interpolated data points for visualization.
|
||||
only_mean (bool, optional): Flag to indicate if only the mean curve should be plotted.
|
||||
|
||||
Notes:
|
||||
The function leverages the '_custom_table' function to generate the actual visualization.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
# Create new x
|
||||
if names is None:
|
||||
names = []
|
||||
x_new = np.linspace(x[0], x[-1], num_x).round(5)
|
||||
|
||||
# Create arrays for logging
|
||||
x_log = x_new.tolist()
|
||||
y_log = np.interp(x_new, x, np.mean(y, axis=0)).round(3).tolist()
|
||||
|
||||
if only_mean:
|
||||
table = wb.Table(data=list(zip(x_log, y_log)), columns=[x_title, y_title])
|
||||
wb.run.log({title: wb.plot.line(table, x_title, y_title, title=title)})
|
||||
else:
|
||||
classes = ["mean"] * len(x_log)
|
||||
for i, yi in enumerate(y):
|
||||
x_log.extend(x_new) # add new x
|
||||
y_log.extend(np.interp(x_new, x, yi)) # interpolate y to new x
|
||||
classes.extend([names[i]] * len(x_new)) # add class names
|
||||
wb.log({id: _custom_table(x_log, y_log, classes, title, x_title, y_title)}, commit=False)
|
||||
|
||||
|
||||
def _log_plots(plots, step):
|
||||
"""Log plots to WandB at a specific step if they haven't been logged already.
|
||||
|
||||
This function checks each plot in the input dictionary against previously processed plots and logs new or updated
|
||||
plots to WandB at the specified step.
|
||||
|
||||
Args:
|
||||
plots (dict): Dictionary of plots to log, where keys are plot names and values are dictionaries containing plot
|
||||
metadata including timestamps.
|
||||
step (int): The step/epoch at which to log the plots in the WandB run.
|
||||
|
||||
Notes:
|
||||
The function uses a shallow copy of the plots dictionary to prevent modification during iteration.
|
||||
Plots are identified by their stem name (filename without extension).
|
||||
Each plot is logged as a WandB Image object.
|
||||
"""
|
||||
for name, params in plots.copy().items(): # shallow copy to prevent plots dict changing during iteration
|
||||
timestamp = params["timestamp"]
|
||||
if _processed_plots.get(name) != timestamp:
|
||||
wb.run.log({name.stem: wb.Image(str(name))}, step=step)
|
||||
_processed_plots[name] = timestamp
|
||||
|
||||
|
||||
def on_pretrain_routine_start(trainer):
|
||||
"""Initialize and start wandb project if module is present."""
|
||||
if not wb.run:
|
||||
from datetime import datetime
|
||||
|
||||
name = str(trainer.args.name).replace("/", "-").replace(" ", "_")
|
||||
wb.init(
|
||||
project=str(trainer.args.project).replace("/", "-") if trainer.args.project else "Ultralytics",
|
||||
name=name,
|
||||
config=vars(trainer.args),
|
||||
id=f"{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}", # add unique id
|
||||
dir=str(trainer.save_dir),
|
||||
)
|
||||
|
||||
|
||||
def on_fit_epoch_end(trainer):
|
||||
"""Log training metrics and model information at the end of an epoch."""
|
||||
_log_plots(trainer.plots, step=trainer.epoch + 1)
|
||||
_log_plots(trainer.validator.plots, step=trainer.epoch + 1)
|
||||
if trainer.epoch == 0:
|
||||
wb.run.log(model_info_for_loggers(trainer), step=trainer.epoch + 1)
|
||||
wb.run.log(trainer.metrics, step=trainer.epoch + 1, commit=True) # commit forces sync
|
||||
|
||||
|
||||
def on_train_epoch_end(trainer):
|
||||
"""Log metrics and save images at the end of each training epoch."""
|
||||
wb.run.log(trainer.label_loss_items(trainer.tloss, prefix="train"), step=trainer.epoch + 1)
|
||||
wb.run.log(trainer.lr, step=trainer.epoch + 1)
|
||||
if trainer.epoch == 1:
|
||||
_log_plots(trainer.plots, step=trainer.epoch + 1)
|
||||
|
||||
|
||||
def on_train_end(trainer):
|
||||
"""Save the best model as an artifact and log final plots at the end of training."""
|
||||
_log_plots(trainer.validator.plots, step=trainer.epoch + 1)
|
||||
_log_plots(trainer.plots, step=trainer.epoch + 1)
|
||||
art = wb.Artifact(type="model", name=f"run_{wb.run.id}_model")
|
||||
if trainer.best.exists():
|
||||
art.add_file(trainer.best)
|
||||
wb.run.log_artifact(art, aliases=["best"])
|
||||
# Check if we actually have plots to save
|
||||
if trainer.args.plots and hasattr(trainer.validator.metrics, "curves_results"):
|
||||
for curve_name, curve_values in zip(trainer.validator.metrics.curves, trainer.validator.metrics.curves_results):
|
||||
x, y, x_title, y_title = curve_values
|
||||
_plot_curve(
|
||||
x,
|
||||
y,
|
||||
names=list(trainer.validator.metrics.names.values()),
|
||||
id=f"curves/{curve_name}",
|
||||
title=curve_name,
|
||||
x_title=x_title,
|
||||
y_title=y_title,
|
||||
)
|
||||
wb.run.finish() # required or run continues on dashboard
|
||||
|
||||
|
||||
callbacks = (
|
||||
{
|
||||
"on_pretrain_routine_start": on_pretrain_routine_start,
|
||||
"on_train_epoch_end": on_train_epoch_end,
|
||||
"on_fit_epoch_end": on_fit_epoch_end,
|
||||
"on_train_end": on_train_end,
|
||||
}
|
||||
if wb
|
||||
else {}
|
||||
)
|
||||
1059
ultralytics/utils/checks.py
Executable file
1059
ultralytics/utils/checks.py
Executable file
File diff suppressed because it is too large
Load Diff
85
ultralytics/utils/cpu.py
Executable file
85
ultralytics/utils/cpu.py
Executable file
@@ -0,0 +1,85 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CPUInfo:
|
||||
"""Provide cross-platform CPU brand and model information.
|
||||
|
||||
Query platform-specific sources to retrieve a human-readable CPU descriptor and normalize it for consistent
|
||||
presentation across macOS, Linux, and Windows. If platform-specific probing fails, generic platform identifiers are
|
||||
used to ensure a stable string is always returned.
|
||||
|
||||
Methods:
|
||||
name: Return the normalized CPU name using platform-specific sources with robust fallbacks.
|
||||
_clean: Normalize and prettify common vendor brand strings and frequency patterns.
|
||||
__str__: Return the normalized CPU name for string contexts.
|
||||
|
||||
Examples:
|
||||
>>> CPUInfo.name()
|
||||
'Apple M4 Pro'
|
||||
>>> str(CPUInfo())
|
||||
'Intel Core i7-9750H 2.60GHz'
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
"""Return a normalized CPU model string from platform-specific sources."""
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
# Query macOS sysctl for the CPU brand string
|
||||
s = subprocess.run(
|
||||
["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
if s:
|
||||
return CPUInfo._clean(s)
|
||||
elif sys.platform.startswith("linux"):
|
||||
# Parse /proc/cpuinfo for the first "model name" entry
|
||||
p = Path("/proc/cpuinfo")
|
||||
if p.exists():
|
||||
for line in p.read_text(errors="ignore").splitlines():
|
||||
if "model name" in line:
|
||||
return CPUInfo._clean(line.split(":", 1)[1])
|
||||
elif sys.platform.startswith("win"):
|
||||
try:
|
||||
import winreg as wr
|
||||
|
||||
with wr.OpenKey(wr.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as k:
|
||||
val, _ = wr.QueryValueEx(k, "ProcessorNameString")
|
||||
if val:
|
||||
return CPUInfo._clean(val)
|
||||
except Exception:
|
||||
# Fall through to generic platform fallbacks on Windows registry access failure
|
||||
pass
|
||||
# Generic platform fallbacks
|
||||
s = platform.processor() or getattr(platform.uname(), "processor", "") or platform.machine()
|
||||
return CPUInfo._clean(s or "Unknown CPU")
|
||||
except Exception:
|
||||
# Ensure a string is always returned even on unexpected failures
|
||||
s = platform.processor() or platform.machine() or ""
|
||||
return CPUInfo._clean(s or "Unknown CPU")
|
||||
|
||||
@staticmethod
|
||||
def _clean(s: str) -> str:
|
||||
"""Normalize and prettify a raw CPU descriptor string."""
|
||||
s = re.sub(r"\s+", " ", s.strip())
|
||||
s = s.replace("(TM)", "").replace("(tm)", "").replace("(R)", "").replace("(r)", "").strip()
|
||||
if m := re.search(r"(Intel.*?i\d[\w-]*) CPU @ ([\d.]+GHz)", s, re.I):
|
||||
return f"{m.group(1)} {m.group(2)}"
|
||||
if m := re.search(r"(AMD.*?Ryzen.*?[\w-]*) CPU @ ([\d.]+GHz)", s, re.I):
|
||||
return f"{m.group(1)} {m.group(2)}"
|
||||
return s
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return the normalized CPU name."""
|
||||
return self.name()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(CPUInfo.name())
|
||||
190
ultralytics/utils/dist.py
Executable file
190
ultralytics/utils/dist.py
Executable file
@@ -0,0 +1,190 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import USER_CONFIG_DIR
|
||||
from .torch_utils import TORCH_1_9
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ultralytics.engine.trainer import BaseTrainer
|
||||
|
||||
|
||||
_RUN_TIMESTAMP_STORES = {}
|
||||
|
||||
|
||||
def find_free_network_port() -> int:
|
||||
"""Find a free port on localhost.
|
||||
|
||||
It is useful in single-node training when we don't want to connect to a real main node but have to set the
|
||||
`MASTER_PORT` environment variable.
|
||||
|
||||
Returns:
|
||||
(int): The available network port number.
|
||||
"""
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1] # port
|
||||
|
||||
|
||||
def _get_run_sync_port(base_name: str) -> int:
|
||||
"""Derive a stable coordination port for sharing a run timestamp across nodes."""
|
||||
override = os.getenv("ULTRALYTICS_RUN_SYNC_PORT")
|
||||
if override:
|
||||
try:
|
||||
return int(override)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("ULTRALYTICS_RUN_SYNC_PORT must be an integer.") from exc
|
||||
|
||||
try:
|
||||
master_port = int(os.getenv("MASTER_PORT", "29500"))
|
||||
except ValueError:
|
||||
master_port = 29500
|
||||
|
||||
offset = 11 + sum(ord(char) for char in base_name) % 97
|
||||
return 1024 + ((master_port + offset - 1024) % 64512)
|
||||
|
||||
|
||||
def get_distributed_run_timestamp(base_name: str, world_size: int, rank: int, *, timeout: timedelta = timedelta(seconds=120)) -> str:
|
||||
"""Return a timestamp string that is shared across all distributed ranks."""
|
||||
if world_size <= 1 or rank == -1:
|
||||
return datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
|
||||
master_addr = os.getenv("MASTER_ADDR")
|
||||
if not master_addr:
|
||||
raise RuntimeError("MASTER_ADDR is required to coordinate a distributed run name.")
|
||||
|
||||
from torch.distributed import TCPStore
|
||||
|
||||
sync_port = _get_run_sync_port(base_name)
|
||||
safe_base_name = "".join(char if char.isalnum() else "_" for char in base_name)
|
||||
key = f"ultralytics_run_timestamp_{safe_base_name}"
|
||||
is_master = rank == 0
|
||||
store_host = "0.0.0.0" if is_master else master_addr
|
||||
|
||||
try:
|
||||
store = TCPStore(store_host, sync_port, world_size, is_master, timeout=timeout, wait_for_workers=True)
|
||||
except TypeError:
|
||||
store = TCPStore(store_host, sync_port, world_size, is_master, timeout)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to coordinate the distributed run timestamp via TCPStore at {master_addr}:{sync_port}. "
|
||||
"Set ULTRALYTICS_RUN_SYNC_PORT to an open cross-node port if needed."
|
||||
) from exc
|
||||
|
||||
# Keep the TCPStore alive for the full process lifetime. Rank 0 hosts the store server, so if this object is
|
||||
# garbage collected as soon as build_run_name() returns, the other ranks can lose the connection mid-handshake.
|
||||
_RUN_TIMESTAMP_STORES[(master_addr, sync_port, world_size, rank)] = store
|
||||
|
||||
if is_master:
|
||||
store.set(key, datetime.now().strftime("%Y%m%d%H%M%S"))
|
||||
|
||||
timestamp = store.get(key)
|
||||
return timestamp.decode("utf-8") if isinstance(timestamp, bytes) else str(timestamp)
|
||||
|
||||
|
||||
def generate_ddp_file(trainer: BaseTrainer) -> str:
|
||||
"""Generate a DDP (Distributed Data Parallel) file for multi-GPU training.
|
||||
|
||||
This function creates a temporary Python file that enables distributed training across multiple GPUs. The file
|
||||
contains the necessary configuration to initialize the trainer in a distributed environment.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The trainer containing training configuration and arguments.
|
||||
Must have args attribute and be a class instance.
|
||||
|
||||
Returns:
|
||||
(str): Path to the generated temporary DDP file.
|
||||
|
||||
Notes:
|
||||
The generated file is saved in the USER_CONFIG_DIR/DDP directory and includes:
|
||||
- Trainer class import
|
||||
- Configuration overrides from the trainer arguments
|
||||
- Model path configuration
|
||||
- Training initialization code
|
||||
"""
|
||||
module, name = f"{trainer.__class__.__module__}.{trainer.__class__.__name__}".rsplit(".", 1)
|
||||
|
||||
content = f"""
|
||||
# Ultralytics Multi-GPU training temp file (should be automatically deleted after use)
|
||||
from pathlib import Path, PosixPath # For model arguments stored as Path instead of str
|
||||
overrides = {vars(trainer.args)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
from {module} import {name}
|
||||
from ultralytics.utils import DEFAULT_CFG_DICT
|
||||
|
||||
cfg = DEFAULT_CFG_DICT.copy()
|
||||
cfg.update(save_dir='') # handle the extra key 'save_dir'
|
||||
trainer = {name}(cfg=cfg, overrides=overrides)
|
||||
trainer.args.model = "{getattr(trainer.hub_session, "model_url", trainer.args.model)}"
|
||||
results = trainer.train()
|
||||
"""
|
||||
(USER_CONFIG_DIR / "DDP").mkdir(exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="_temp_",
|
||||
suffix=f"{id(trainer)}.py",
|
||||
mode="w+",
|
||||
encoding="utf-8",
|
||||
dir=USER_CONFIG_DIR / "DDP",
|
||||
delete=False,
|
||||
) as file:
|
||||
file.write(content)
|
||||
return file.name
|
||||
|
||||
|
||||
def generate_ddp_command(trainer: BaseTrainer) -> tuple[list[str], str]:
|
||||
"""Generate command for distributed training.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The trainer containing configuration for distributed training.
|
||||
|
||||
Returns:
|
||||
cmd (list[str]): The command to execute for distributed training.
|
||||
file (str): Path to the temporary file created for DDP training.
|
||||
"""
|
||||
import __main__ # noqa local import to avoid https://github.com/Lightning-AI/pytorch-lightning/issues/15218
|
||||
|
||||
if not trainer.resume:
|
||||
shutil.rmtree(trainer.save_dir) # remove the save_dir
|
||||
file = generate_ddp_file(trainer)
|
||||
dist_cmd = "torch.distributed.run" if TORCH_1_9 else "torch.distributed.launch"
|
||||
port = find_free_network_port()
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
dist_cmd,
|
||||
"--nproc_per_node",
|
||||
f"{trainer.world_size}",
|
||||
"--master_port",
|
||||
f"{port}",
|
||||
file,
|
||||
]
|
||||
return cmd, file
|
||||
|
||||
|
||||
def ddp_cleanup(trainer: BaseTrainer, file: str) -> None:
|
||||
"""Delete temporary file if created during distributed data parallel (DDP) training.
|
||||
|
||||
This function checks if the provided file contains the trainer's ID in its name, indicating it was created as a
|
||||
temporary file for DDP training, and deletes it if so.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The trainer used for distributed training.
|
||||
file (str): Path to the file that might need to be deleted.
|
||||
|
||||
Examples:
|
||||
>>> trainer = YOLOTrainer()
|
||||
>>> file = "/tmp/ddp_temp_123456789.py"
|
||||
>>> ddp_cleanup(trainer, file)
|
||||
"""
|
||||
if f"{id(trainer)}.py" in file: # if temp_file suffix in file
|
||||
os.remove(file)
|
||||
535
ultralytics/utils/downloads.py
Executable file
535
ultralytics/utils/downloads.py
Executable file
@@ -0,0 +1,535 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from itertools import repeat
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from pathlib import Path
|
||||
from urllib import parse, request
|
||||
|
||||
from ultralytics.utils import ASSETS_URL, LOGGER, TQDM, checks, clean_url, emojis, is_online, url2file
|
||||
|
||||
# Define Ultralytics GitHub assets maintained at https://github.com/ultralytics/assets
|
||||
GITHUB_ASSETS_REPO = "ultralytics/assets"
|
||||
GITHUB_ASSETS_NAMES = frozenset(
|
||||
[f"yolov8{k}{suffix}.pt" for k in "nsmlx" for suffix in ("", "-cls", "-seg", "-pose", "-obb", "-oiv7")]
|
||||
+ [f"yolo11{k}{suffix}.pt" for k in "nsmlx" for suffix in ("", "-cls", "-seg", "-pose", "-obb")]
|
||||
+ [f"yolo12{k}{suffix}.pt" for k in "nsmlx" for suffix in ("",)] # detect models only currently
|
||||
+ [f"yolo26{k}{suffix}.pt" for k in "nsmlx" for suffix in ("", "-cls", "-seg", "-pose", "-obb")]
|
||||
+ [f"yolov5{k}{resolution}u.pt" for k in "nsmlx" for resolution in ("", "6")]
|
||||
+ [f"yolov3{k}u.pt" for k in ("", "-spp", "-tiny")]
|
||||
+ [f"yolov8{k}-world.pt" for k in "smlx"]
|
||||
+ [f"yolov8{k}-worldv2.pt" for k in "smlx"]
|
||||
+ [f"yoloe-v8{k}{suffix}.pt" for k in "sml" for suffix in ("-seg", "-seg-pf")]
|
||||
+ [f"yoloe-11{k}{suffix}.pt" for k in "sml" for suffix in ("-seg", "-seg-pf")]
|
||||
+ [f"yoloe-26{k}{suffix}.pt" for k in "nsmlx" for suffix in ("-seg", "-seg-pf")]
|
||||
+ [f"yolov9{k}.pt" for k in "tsmce"]
|
||||
+ [f"yolov10{k}.pt" for k in "nsmblx"]
|
||||
+ [f"yolo_nas_{k}.pt" for k in "sml"]
|
||||
+ [f"sam_{k}.pt" for k in "bl"]
|
||||
+ [f"sam2_{k}.pt" for k in "blst"]
|
||||
+ [f"sam2.1_{k}.pt" for k in "blst"]
|
||||
+ [f"FastSAM-{k}.pt" for k in "sx"]
|
||||
+ [f"rtdetr-{k}.pt" for k in "lx"]
|
||||
+ [
|
||||
"mobile_sam.pt",
|
||||
"mobileclip_blt.ts",
|
||||
"yolo11n-grayscale.pt",
|
||||
"calibration_image_sample_data_20x128x128x3_float32.npy.zip",
|
||||
]
|
||||
)
|
||||
GITHUB_ASSETS_STEMS = frozenset(k.rpartition(".")[0] for k in GITHUB_ASSETS_NAMES)
|
||||
|
||||
|
||||
def is_url(url: str | Path, check: bool = False) -> bool:
|
||||
"""Validate if the given string is a URL and optionally check if the URL exists online.
|
||||
|
||||
Args:
|
||||
url (str | Path): The string to be validated as a URL.
|
||||
check (bool, optional): If True, performs an additional check to see if the URL exists online.
|
||||
|
||||
Returns:
|
||||
(bool): True for a valid URL. If 'check' is True, also returns True if the URL exists online.
|
||||
|
||||
Examples:
|
||||
>>> valid = is_url("https://www.example.com")
|
||||
>>> valid_and_exists = is_url("https://www.example.com", check=True)
|
||||
"""
|
||||
try:
|
||||
url = str(url)
|
||||
result = parse.urlparse(url)
|
||||
if not (result.scheme and result.netloc):
|
||||
return False
|
||||
if check:
|
||||
r = request.urlopen(request.Request(url, method="HEAD"), timeout=3)
|
||||
return 200 <= r.getcode() < 400
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def delete_dsstore(path: str | Path, files_to_delete: tuple[str, ...] = (".DS_Store", "__MACOSX")) -> None:
|
||||
"""Delete all specified system files in a directory.
|
||||
|
||||
Args:
|
||||
path (str | Path): The directory path where the files should be deleted.
|
||||
files_to_delete (tuple[str, ...]): The files to be deleted.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.downloads import delete_dsstore
|
||||
>>> delete_dsstore("path/to/dir")
|
||||
|
||||
Notes:
|
||||
".DS_Store" files are created by the Apple operating system and contain metadata about folders and files. They
|
||||
are hidden system files and can cause issues when transferring files between different operating systems.
|
||||
"""
|
||||
for file in files_to_delete:
|
||||
matches = list(Path(path).rglob(file))
|
||||
LOGGER.info(f"Deleting {file} files: {matches}")
|
||||
for f in matches:
|
||||
f.unlink()
|
||||
|
||||
|
||||
def zip_directory(
|
||||
directory: str | Path,
|
||||
compress: bool = True,
|
||||
exclude: tuple[str, ...] = (".DS_Store", "__MACOSX"),
|
||||
progress: bool = True,
|
||||
) -> Path:
|
||||
"""Zip the contents of a directory, excluding specified files.
|
||||
|
||||
The resulting zip file is named after the directory and placed alongside it.
|
||||
|
||||
Args:
|
||||
directory (str | Path): The path to the directory to be zipped.
|
||||
compress (bool): Whether to compress the files while zipping.
|
||||
exclude (tuple[str, ...], optional): A tuple of filename strings to be excluded.
|
||||
progress (bool, optional): Whether to display a progress bar.
|
||||
|
||||
Returns:
|
||||
(Path): The path to the resulting zip file.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.downloads import zip_directory
|
||||
>>> file = zip_directory("path/to/dir")
|
||||
"""
|
||||
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
|
||||
|
||||
delete_dsstore(directory)
|
||||
directory = Path(directory)
|
||||
if not directory.is_dir():
|
||||
raise FileNotFoundError(f"Directory '{directory}' does not exist.")
|
||||
|
||||
# Zip with progress bar
|
||||
files = [f for f in directory.rglob("*") if f.is_file() and all(x not in f.name for x in exclude)] # files to zip
|
||||
zip_file = directory.with_suffix(".zip")
|
||||
compression = ZIP_DEFLATED if compress else ZIP_STORED
|
||||
with ZipFile(zip_file, "w", compression) as f:
|
||||
for file in TQDM(files, desc=f"Zipping {directory} to {zip_file}...", unit="files", disable=not progress):
|
||||
f.write(file, file.relative_to(directory))
|
||||
|
||||
return zip_file # return path to zip file
|
||||
|
||||
|
||||
def unzip_file(
|
||||
file: str | Path,
|
||||
path: str | Path | None = None,
|
||||
exclude: tuple[str, ...] = (".DS_Store", "__MACOSX"),
|
||||
exist_ok: bool = False,
|
||||
progress: bool = True,
|
||||
) -> Path:
|
||||
"""Unzip a *.zip file to the specified path, excluding specified files.
|
||||
|
||||
If the zipfile does not contain a single top-level directory, the function will create a new directory with the same
|
||||
name as the zipfile (without the extension) to extract its contents. If a path is not provided, the function will
|
||||
use the parent directory of the zipfile as the default path.
|
||||
|
||||
Args:
|
||||
file (str | Path): The path to the zipfile to be extracted.
|
||||
path (str | Path, optional): The path to extract the zipfile to.
|
||||
exclude (tuple[str, ...], optional): A tuple of filename strings to be excluded.
|
||||
exist_ok (bool, optional): Whether to overwrite existing contents if they exist.
|
||||
progress (bool, optional): Whether to display a progress bar.
|
||||
|
||||
Returns:
|
||||
(Path): The path to the directory where the zipfile was extracted.
|
||||
|
||||
Raises:
|
||||
BadZipFile: If the provided file does not exist or is not a valid zipfile.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.downloads import unzip_file
|
||||
>>> directory = unzip_file("path/to/file.zip")
|
||||
"""
|
||||
from zipfile import BadZipFile, ZipFile, is_zipfile
|
||||
|
||||
if not (Path(file).exists() and is_zipfile(file)):
|
||||
raise BadZipFile(f"File '{file}' does not exist or is a bad zip file.")
|
||||
if path is None:
|
||||
path = Path(file).parent # default path
|
||||
|
||||
# Unzip the file contents
|
||||
with ZipFile(file) as zipObj:
|
||||
files = [f for f in zipObj.namelist() if all(x not in f for x in exclude)]
|
||||
top_level_dirs = {Path(f).parts[0] for f in files}
|
||||
|
||||
# Decide to unzip directly or unzip into a directory
|
||||
unzip_as_dir = len(top_level_dirs) == 1 # (len(files) > 1 and not files[0].endswith("/"))
|
||||
if unzip_as_dir:
|
||||
# Zip has 1 top-level directory
|
||||
extract_path = path # i.e. ../datasets
|
||||
path = Path(path) / next(iter(top_level_dirs)) # i.e. extract coco8/ dir to ../datasets/
|
||||
else:
|
||||
# Zip has multiple files at top level
|
||||
path = extract_path = Path(path) / Path(file).stem # i.e. extract multiple files to ../datasets/coco8/
|
||||
|
||||
# Check if destination directory already exists and contains files
|
||||
if path.exists() and any(path.iterdir()) and not exist_ok:
|
||||
# If it exists and is not empty, return the path without unzipping
|
||||
LOGGER.warning(f"Skipping {file} unzip as destination directory {path} is not empty.")
|
||||
return path
|
||||
|
||||
for f in TQDM(files, desc=f"Unzipping {file} to {Path(path).resolve()}...", unit="files", disable=not progress):
|
||||
# Ensure the file is within the extract_path to avoid path traversal security vulnerability
|
||||
if ".." in Path(f).parts:
|
||||
LOGGER.warning(f"Potentially insecure file path: {f}, skipping extraction.")
|
||||
continue
|
||||
zipObj.extract(f, extract_path)
|
||||
|
||||
return path # return unzip dir
|
||||
|
||||
|
||||
def check_disk_space(
|
||||
file_bytes: int,
|
||||
path: str | Path = Path.cwd(),
|
||||
sf: float = 1.5,
|
||||
hard: bool = True,
|
||||
) -> bool:
|
||||
"""Check if there is sufficient disk space to download and store a file.
|
||||
|
||||
Args:
|
||||
file_bytes (int): The file size in bytes.
|
||||
path (str | Path, optional): The path or drive to check the available free space on.
|
||||
sf (float, optional): Safety factor, the multiplier for the required free space.
|
||||
hard (bool, optional): Whether to throw an error or not on insufficient disk space.
|
||||
|
||||
Returns:
|
||||
(bool): True if there is sufficient disk space, False otherwise.
|
||||
"""
|
||||
_total, _used, free = shutil.disk_usage(path) # bytes
|
||||
if file_bytes * sf < free:
|
||||
return True # sufficient space
|
||||
|
||||
def fmt_bytes(b):
|
||||
return f"{b / (1 << 20):.1f} MB" if b < (1 << 30) else f"{b / (1 << 30):.3f} GB"
|
||||
|
||||
# Insufficient space
|
||||
text = (
|
||||
f"Insufficient free disk space {fmt_bytes(free)} < {fmt_bytes(int(file_bytes * sf))} required, "
|
||||
f"Please free {fmt_bytes(int(file_bytes * sf - free))} additional disk space and try again."
|
||||
)
|
||||
if hard:
|
||||
raise MemoryError(text)
|
||||
LOGGER.warning(text)
|
||||
return False
|
||||
|
||||
|
||||
def get_google_drive_file_info(link: str) -> tuple[str, str | None]:
|
||||
"""Retrieve the direct download link and filename for a shareable Google Drive file link.
|
||||
|
||||
Args:
|
||||
link (str): The shareable link of the Google Drive file.
|
||||
|
||||
Returns:
|
||||
url (str): Direct download URL for the Google Drive file.
|
||||
filename (str | None): Original filename of the Google Drive file. If filename extraction fails, returns None.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.downloads import get_google_drive_file_info
|
||||
>>> link = "https://drive.google.com/file/d/1cqT-cJgANNrhIHCrEufUYhQ4RqiWG_lJ/view?usp=drive_link"
|
||||
>>> url, filename = get_google_drive_file_info(link)
|
||||
"""
|
||||
import requests # scoped as slow import
|
||||
|
||||
file_id = link.split("/d/")[1].split("/view", 1)[0]
|
||||
drive_url = f"https://drive.google.com/uc?export=download&id={file_id}"
|
||||
filename = None
|
||||
|
||||
# Start session
|
||||
with requests.Session() as session:
|
||||
response = session.get(drive_url, stream=True)
|
||||
if "quota exceeded" in str(response.content.lower()):
|
||||
raise ConnectionError(
|
||||
emojis(
|
||||
f"❌ Google Drive file download quota exceeded. "
|
||||
f"Please try again later or download this file manually at {link}."
|
||||
)
|
||||
)
|
||||
for k, v in response.cookies.items():
|
||||
if k.startswith("download_warning"):
|
||||
drive_url += f"&confirm={v}" # v is token
|
||||
if cd := response.headers.get("content-disposition"):
|
||||
filename = re.findall('filename="(.+)"', cd)[0]
|
||||
return drive_url, filename
|
||||
|
||||
|
||||
def safe_download(
|
||||
url: str | Path,
|
||||
file: str | Path | None = None,
|
||||
dir: str | Path | None = None,
|
||||
unzip: bool = True,
|
||||
delete: bool = False,
|
||||
curl: bool = False,
|
||||
retry: int = 3,
|
||||
min_bytes: float = 1e0,
|
||||
exist_ok: bool = False,
|
||||
progress: bool = True,
|
||||
) -> Path | str:
|
||||
"""Download files from a URL with options for retrying, unzipping, and deleting the downloaded file. Enhanced with
|
||||
robust partial download detection using Content-Length validation.
|
||||
|
||||
Args:
|
||||
url (str | Path): The URL of the file to be downloaded.
|
||||
file (str | Path, optional): The filename of the downloaded file. If not provided, the file will be saved with
|
||||
the same name as the URL.
|
||||
dir (str | Path, optional): The directory to save the downloaded file. If not provided, the file will be saved
|
||||
in the current working directory.
|
||||
unzip (bool, optional): Whether to unzip the downloaded file.
|
||||
delete (bool, optional): Whether to delete the downloaded file after unzipping.
|
||||
curl (bool, optional): Whether to use curl command line tool for downloading.
|
||||
retry (int, optional): The number of times to retry the download in case of failure.
|
||||
min_bytes (float, optional): The minimum number of bytes that the downloaded file should have, to be considered
|
||||
a successful download.
|
||||
exist_ok (bool, optional): Whether to overwrite existing contents during unzipping.
|
||||
progress (bool, optional): Whether to display a progress bar during the download.
|
||||
|
||||
Returns:
|
||||
(Path | str): The path to the downloaded file or extracted directory.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.downloads import safe_download
|
||||
>>> link = "https://ultralytics.com/assets/bus.jpg"
|
||||
>>> path = safe_download(link)
|
||||
"""
|
||||
gdrive = url.startswith("https://drive.google.com/") # check if the URL is a Google Drive link
|
||||
if gdrive:
|
||||
url, file = get_google_drive_file_info(url)
|
||||
url = url.replace(" ", "%20") # encode spaces for curl/urllib compatibility
|
||||
|
||||
f = Path(dir or ".") / (file or url2file(url)) # URL converted to filename
|
||||
if "://" not in str(url) and Path(url).is_file(): # URL exists ('://' check required in Windows Python<3.10)
|
||||
f = Path(url) # filename
|
||||
elif not f.is_file(): # URL and file do not exist
|
||||
uri = (url if gdrive else clean_url(url)).replace(ASSETS_URL, "https://ultralytics.com/assets") # clean
|
||||
desc = f"Downloading {uri} to '{f}'"
|
||||
f.parent.mkdir(parents=True, exist_ok=True) # make directory if missing
|
||||
curl_installed = shutil.which("curl")
|
||||
for i in range(retry + 1):
|
||||
try:
|
||||
if (curl or i > 0) and curl_installed: # curl download with retry, continue
|
||||
s = "sS" * (not progress) # silent
|
||||
r = subprocess.run(["curl", "-#", f"-{s}L", url, "-o", f, "--retry", "3", "-C", "-"]).returncode
|
||||
assert r == 0, f"Curl return value {r}"
|
||||
expected_size = None # Can't get size with curl
|
||||
else: # urllib download
|
||||
with request.urlopen(url) as response:
|
||||
expected_size = int(response.getheader("Content-Length", 0))
|
||||
if i == 0 and expected_size > 1048576:
|
||||
check_disk_space(expected_size, path=f.parent)
|
||||
buffer_size = max(8192, min(1048576, expected_size // 1000)) if expected_size else 8192
|
||||
with TQDM(
|
||||
total=expected_size,
|
||||
desc=desc,
|
||||
disable=not progress,
|
||||
unit="B",
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
) as pbar:
|
||||
with open(f, "wb") as f_opened:
|
||||
while True:
|
||||
data = response.read(buffer_size)
|
||||
if not data:
|
||||
break
|
||||
f_opened.write(data)
|
||||
pbar.update(len(data))
|
||||
|
||||
if f.exists():
|
||||
file_size = f.stat().st_size
|
||||
if file_size > min_bytes:
|
||||
# Check if download is complete (only if we have expected_size)
|
||||
if expected_size and file_size != expected_size:
|
||||
LOGGER.warning(
|
||||
f"Partial download: {file_size}/{expected_size} bytes ({file_size / expected_size * 100:.1f}%)"
|
||||
)
|
||||
else:
|
||||
break # success
|
||||
f.unlink() # remove partial downloads
|
||||
except MemoryError:
|
||||
raise # Re-raise immediately - no point retrying if insufficient disk space
|
||||
except Exception as e:
|
||||
if i == 0 and not is_online():
|
||||
raise ConnectionError(emojis(f"❌ Download failure for {uri}. Environment may be offline.")) from e
|
||||
elif i >= retry:
|
||||
raise ConnectionError(emojis(f"❌ Download failure for {uri}. Retry limit reached. {e}")) from e
|
||||
LOGGER.warning(f"Download failure, retrying {i + 1}/{retry} {uri}... {e}")
|
||||
|
||||
if unzip and f.exists() and f.suffix in {"", ".zip", ".tar", ".gz"}:
|
||||
from zipfile import is_zipfile
|
||||
|
||||
unzip_dir = (dir or f.parent).resolve() # unzip to dir if provided else unzip in place
|
||||
if is_zipfile(f):
|
||||
unzip_dir = unzip_file(file=f, path=unzip_dir, exist_ok=exist_ok, progress=progress) # unzip
|
||||
elif f.suffix in {".tar", ".gz"}:
|
||||
LOGGER.info(f"Unzipping {f} to {unzip_dir}...")
|
||||
subprocess.run(["tar", "xf" if f.suffix == ".tar" else "xfz", f, "--directory", unzip_dir], check=True)
|
||||
if delete:
|
||||
f.unlink() # remove zip
|
||||
return unzip_dir
|
||||
return f
|
||||
|
||||
|
||||
def get_github_assets(
|
||||
repo: str = "ultralytics/assets",
|
||||
version: str = "latest",
|
||||
retry: bool = False,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Retrieve the specified version's tag and assets from a GitHub repository.
|
||||
|
||||
If the version is not specified, the function fetches the latest release assets.
|
||||
|
||||
Args:
|
||||
repo (str, optional): The GitHub repository in the format 'owner/repo'.
|
||||
version (str, optional): The release version to fetch assets from.
|
||||
retry (bool, optional): Flag to retry the request in case of a failure.
|
||||
|
||||
Returns:
|
||||
tag (str): The release tag.
|
||||
assets (list[str]): A list of asset names.
|
||||
|
||||
Examples:
|
||||
>>> tag, assets = get_github_assets(repo="ultralytics/assets", version="latest")
|
||||
"""
|
||||
import requests # scoped as slow import
|
||||
|
||||
if version != "latest":
|
||||
version = f"tags/{version}" # i.e. tags/v6.2
|
||||
url = f"https://api.github.com/repos/{repo}/releases/{version}"
|
||||
r = requests.get(url) # github api
|
||||
if r.status_code != 200 and r.reason != "rate limit exceeded" and retry: # failed and not 403 rate limit exceeded
|
||||
r = requests.get(url) # try again
|
||||
if r.status_code != 200:
|
||||
LOGGER.warning(f"GitHub assets check failure for {url}: {r.status_code} {r.reason}")
|
||||
return "", []
|
||||
data = r.json()
|
||||
return data["tag_name"], [x["name"] for x in data["assets"]] # tag, assets i.e. ['yolo26n.pt', 'yolo11s.pt', ...]
|
||||
|
||||
|
||||
def attempt_download_asset(
|
||||
file: str | Path,
|
||||
repo: str = "ultralytics/assets",
|
||||
release: str = "v8.4.0",
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Attempt to download a file from GitHub release assets if it is not found locally.
|
||||
|
||||
Args:
|
||||
file (str | Path): The filename or file path to be downloaded.
|
||||
repo (str, optional): The GitHub repository in the format 'owner/repo'.
|
||||
release (str, optional): The specific release version to be downloaded.
|
||||
**kwargs (Any): Additional keyword arguments for the download process.
|
||||
|
||||
Returns:
|
||||
(str): The path to the downloaded file.
|
||||
|
||||
Examples:
|
||||
>>> file_path = attempt_download_asset("yolo26n.pt", repo="ultralytics/assets", release="latest")
|
||||
"""
|
||||
from ultralytics.utils import SETTINGS # scoped for circular import
|
||||
|
||||
# YOLOv3/5u updates
|
||||
file = str(file)
|
||||
file = checks.check_yolov5u_filename(file)
|
||||
file = Path(file.strip().replace("'", ""))
|
||||
if file.exists():
|
||||
return str(file)
|
||||
elif (SETTINGS["weights_dir"] / file).exists():
|
||||
return str(SETTINGS["weights_dir"] / file)
|
||||
else:
|
||||
# URL specified
|
||||
name = Path(parse.unquote(str(file))).name # decode '%2F' to '/' etc.
|
||||
download_url = f"https://github.com/{repo}/releases/download"
|
||||
if str(file).startswith(("http:/", "https:/")): # download
|
||||
url = str(file).replace(":/", "://") # Pathlib turns :// -> :/
|
||||
file = url2file(name) # parse authentication https://url.com/file.txt?auth...
|
||||
if Path(file).is_file():
|
||||
LOGGER.info(f"Found {clean_url(url)} locally at {file}") # file already exists
|
||||
else:
|
||||
safe_download(url=url, file=file, min_bytes=1e5, **kwargs)
|
||||
|
||||
elif repo == GITHUB_ASSETS_REPO and name in GITHUB_ASSETS_NAMES:
|
||||
safe_download(url=f"{download_url}/{release}/{name}", file=file, min_bytes=1e5, **kwargs)
|
||||
|
||||
else:
|
||||
tag, assets = get_github_assets(repo, release)
|
||||
if not assets:
|
||||
tag, assets = get_github_assets(repo) # latest release
|
||||
if name in assets:
|
||||
safe_download(url=f"{download_url}/{tag}/{name}", file=file, min_bytes=1e5, **kwargs)
|
||||
|
||||
return str(file)
|
||||
|
||||
|
||||
def download(
|
||||
url: str | list[str] | Path,
|
||||
dir: Path = Path.cwd(),
|
||||
unzip: bool = True,
|
||||
delete: bool = False,
|
||||
curl: bool = False,
|
||||
threads: int = 1,
|
||||
retry: int = 3,
|
||||
exist_ok: bool = False,
|
||||
) -> None:
|
||||
"""Download files from specified URLs to a given directory.
|
||||
|
||||
Supports concurrent downloads if multiple threads are specified.
|
||||
|
||||
Args:
|
||||
url (str | list[str] | Path): The URL or list of URLs of the files to be downloaded.
|
||||
dir (Path, optional): The directory where the files will be saved.
|
||||
unzip (bool, optional): Flag to unzip the files after downloading.
|
||||
delete (bool, optional): Flag to delete the zip files after extraction.
|
||||
curl (bool, optional): Flag to use curl for downloading.
|
||||
threads (int, optional): Number of threads to use for concurrent downloads.
|
||||
retry (int, optional): Number of retries in case of download failure.
|
||||
exist_ok (bool, optional): Whether to overwrite existing contents during unzipping.
|
||||
|
||||
Examples:
|
||||
>>> download("https://ultralytics.com/assets/example.zip", dir="path/to/dir", unzip=True)
|
||||
"""
|
||||
dir = Path(dir)
|
||||
dir.mkdir(parents=True, exist_ok=True) # make directory
|
||||
urls = [url] if isinstance(url, (str, Path)) else url
|
||||
if threads > 1:
|
||||
LOGGER.info(f"Downloading {len(urls)} file(s) with {threads} threads to {dir}...")
|
||||
with ThreadPool(threads) as pool:
|
||||
pool.map(
|
||||
lambda x: safe_download(
|
||||
url=x[0],
|
||||
dir=x[1],
|
||||
unzip=unzip,
|
||||
delete=delete,
|
||||
curl=curl,
|
||||
retry=retry,
|
||||
exist_ok=exist_ok,
|
||||
progress=True,
|
||||
),
|
||||
zip(urls, repeat(dir)),
|
||||
)
|
||||
pool.close()
|
||||
pool.join()
|
||||
else:
|
||||
for u in urls:
|
||||
safe_download(url=u, dir=dir, unzip=unzip, delete=delete, curl=curl, retry=retry, exist_ok=exist_ok)
|
||||
35
ultralytics/utils/errors.py
Executable file
35
ultralytics/utils/errors.py
Executable file
@@ -0,0 +1,35 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from ultralytics.utils import emojis
|
||||
|
||||
|
||||
class HUBModelError(Exception):
|
||||
"""Exception raised when a model cannot be found or retrieved from Ultralytics HUB.
|
||||
|
||||
This custom exception is used specifically for handling errors related to model fetching in Ultralytics YOLO. The
|
||||
error message is processed to include emojis for better user experience.
|
||||
|
||||
Attributes:
|
||||
message (str): The error message displayed when the exception is raised.
|
||||
|
||||
Methods:
|
||||
__init__: Initialize the HUBModelError with a custom message.
|
||||
|
||||
Examples:
|
||||
>>> try:
|
||||
... # Code that might fail to find a model
|
||||
... raise HUBModelError("Custom model not found message")
|
||||
... except HUBModelError as e:
|
||||
... print(e) # Displays the emoji-enhanced error message
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "Model not found. Please check model URL and try again."):
|
||||
"""Initialize a HUBModelError exception.
|
||||
|
||||
This exception is raised when a requested model is not found or cannot be retrieved from Ultralytics HUB. The
|
||||
message is processed to include emojis for better user experience.
|
||||
|
||||
Args:
|
||||
message (str, optional): The error message to display when the exception is raised.
|
||||
"""
|
||||
super().__init__(emojis(message))
|
||||
113
ultralytics/utils/events.py
Executable file
113
ultralytics/utils/events.py
Executable file
@@ -0,0 +1,113 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from ultralytics import SETTINGS, __version__
|
||||
from ultralytics.utils import ARGV, ENVIRONMENT, GIT, IS_PIP_PACKAGE, ONLINE, PYTHON_VERSION, RANK, TESTS_RUNNING
|
||||
from ultralytics.utils.downloads import GITHUB_ASSETS_NAMES
|
||||
from ultralytics.utils.torch_utils import get_cpu_info
|
||||
|
||||
|
||||
def _post(url: str, data: dict, timeout: float = 5.0) -> None:
|
||||
"""Send a one-shot JSON POST request."""
|
||||
try:
|
||||
body = json.dumps(data, separators=(",", ":")).encode() # compact JSON
|
||||
req = Request(url, data=body, headers={"Content-Type": "application/json"})
|
||||
urlopen(req, timeout=timeout).close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Events:
|
||||
"""Collect and send anonymous usage analytics with rate-limiting.
|
||||
|
||||
Event collection and transmission are enabled when sync is enabled in settings, the current process is rank -1 or 0,
|
||||
tests are not running, the environment is online, and the installation source is either pip or the official
|
||||
Ultralytics GitHub repository.
|
||||
|
||||
Attributes:
|
||||
url (str): Measurement Protocol endpoint for receiving anonymous events.
|
||||
events (list[dict]): In-memory queue of event payloads awaiting transmission.
|
||||
rate_limit (float): Minimum time in seconds between POST requests.
|
||||
t (float): Timestamp of the last transmission in seconds since the epoch.
|
||||
metadata (dict): Static metadata describing runtime, installation source, and environment.
|
||||
enabled (bool): Flag indicating whether analytics collection is active.
|
||||
|
||||
Methods:
|
||||
__init__: Initialize the event queue, rate limiter, and runtime metadata.
|
||||
__call__: Queue an event and trigger a non-blocking send when the rate limit elapses.
|
||||
"""
|
||||
|
||||
url = "https://www.google-analytics.com/mp/collect?measurement_id=G-X8NCJYTQXM&api_secret=QLQrATrNSwGRFRLE-cbHJw"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Events instance with queue, rate limiter, and environment metadata."""
|
||||
self.events = [] # pending events
|
||||
self.rate_limit = 30.0 # rate limit (seconds)
|
||||
self.t = 0.0 # last send timestamp (seconds)
|
||||
self.metadata = {
|
||||
"cli": Path(ARGV[0]).name == "yolo",
|
||||
"install": "git" if GIT.is_repo else "pip" if IS_PIP_PACKAGE else "other",
|
||||
"python": PYTHON_VERSION.rsplit(".", 1)[0], # i.e. 3.13
|
||||
"CPU": get_cpu_info(),
|
||||
# "GPU": get_gpu_info(index=0) if cuda else None,
|
||||
"version": __version__,
|
||||
"env": ENVIRONMENT,
|
||||
"session_id": round(random.random() * 1e15),
|
||||
"engagement_time_msec": 1000,
|
||||
}
|
||||
self.enabled = (
|
||||
SETTINGS["sync"]
|
||||
and RANK in {-1, 0}
|
||||
and not TESTS_RUNNING
|
||||
and ONLINE
|
||||
and (IS_PIP_PACKAGE or GIT.origin == "https://github.com/ultralytics/ultralytics.git")
|
||||
)
|
||||
|
||||
def __call__(self, cfg, device=None) -> None:
|
||||
"""Queue an event and flush the queue asynchronously when the rate limit elapses.
|
||||
|
||||
Args:
|
||||
cfg (IterableSimpleNamespace): The configuration object containing mode and task information.
|
||||
device (torch.device | str, optional): The device type (e.g., 'cpu', 'cuda').
|
||||
"""
|
||||
if not self.enabled:
|
||||
# Events disabled, do nothing
|
||||
return
|
||||
|
||||
# Attempt to enqueue a new event
|
||||
if len(self.events) < 25: # Queue limited to 25 events to bound memory and traffic
|
||||
params = {
|
||||
**self.metadata,
|
||||
"task": cfg.task,
|
||||
"model": cfg.model if cfg.model in GITHUB_ASSETS_NAMES else "custom",
|
||||
"device": str(device),
|
||||
}
|
||||
if cfg.mode == "export":
|
||||
params["format"] = cfg.format
|
||||
self.events.append({"name": cfg.mode, "params": params})
|
||||
|
||||
# Check rate limit and return early if under limit
|
||||
t = time.time()
|
||||
if (t - self.t) < self.rate_limit:
|
||||
return
|
||||
|
||||
# Overrate limit: send a snapshot of queued events in a background thread
|
||||
payload_events = list(self.events) # snapshot to avoid race with queue reset
|
||||
Thread(
|
||||
target=_post,
|
||||
args=(self.url, {"client_id": SETTINGS["uuid"], "events": payload_events}), # SHA-256 anonymized
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
# Reset queue and rate limit timer
|
||||
self.events = []
|
||||
self.t = t
|
||||
|
||||
|
||||
events = Events()
|
||||
17
ultralytics/utils/export/__init__.py
Executable file
17
ultralytics/utils/export/__init__.py
Executable file
@@ -0,0 +1,17 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from .engine import onnx2engine, torch2onnx
|
||||
from .executorch import torch2executorch
|
||||
from .imx import torch2imx
|
||||
from .tensorflow import keras2pb, onnx2saved_model, pb2tfjs, tflite2edgetpu
|
||||
|
||||
__all__ = [
|
||||
"keras2pb",
|
||||
"onnx2engine",
|
||||
"onnx2saved_model",
|
||||
"pb2tfjs",
|
||||
"tflite2edgetpu",
|
||||
"torch2executorch",
|
||||
"torch2imx",
|
||||
"torch2onnx",
|
||||
]
|
||||
246
ultralytics/utils/export/engine.py
Executable file
246
ultralytics/utils/export/engine.py
Executable file
@@ -0,0 +1,246 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
from ultralytics.utils import IS_JETSON, LOGGER
|
||||
from ultralytics.utils.torch_utils import TORCH_2_4
|
||||
|
||||
|
||||
def torch2onnx(
|
||||
torch_model: torch.nn.Module,
|
||||
im: torch.Tensor,
|
||||
onnx_file: str,
|
||||
opset: int = 14,
|
||||
input_names: list[str] = ["images"],
|
||||
output_names: list[str] = ["output0"],
|
||||
dynamic: bool | dict = False,
|
||||
) -> None:
|
||||
"""Export a PyTorch model to ONNX format.
|
||||
|
||||
Args:
|
||||
torch_model (torch.nn.Module): The PyTorch model to export.
|
||||
im (torch.Tensor): Example input tensor for the model.
|
||||
onnx_file (str): Path to save the exported ONNX file.
|
||||
opset (int): ONNX opset version to use for export.
|
||||
input_names (list[str]): List of input tensor names.
|
||||
output_names (list[str]): List of output tensor names.
|
||||
dynamic (bool | dict, optional): Whether to enable dynamic axes.
|
||||
|
||||
Notes:
|
||||
Setting `do_constant_folding=True` may cause issues with DNN inference for torch>=1.12.
|
||||
"""
|
||||
kwargs = {"dynamo": False} if TORCH_2_4 else {}
|
||||
torch.onnx.export(
|
||||
torch_model,
|
||||
im,
|
||||
onnx_file,
|
||||
verbose=False,
|
||||
opset_version=opset,
|
||||
do_constant_folding=True, # WARNING: DNN inference with torch>=1.12 may require do_constant_folding=False
|
||||
input_names=input_names,
|
||||
output_names=output_names,
|
||||
dynamic_axes=dynamic or None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def onnx2engine(
|
||||
onnx_file: str,
|
||||
engine_file: str | None = None,
|
||||
workspace: int | None = None,
|
||||
half: bool = False,
|
||||
int8: bool = False,
|
||||
dynamic: bool = False,
|
||||
shape: tuple[int, int, int, int] = (1, 3, 640, 640),
|
||||
dla: int | None = None,
|
||||
dataset=None,
|
||||
metadata: dict | None = None,
|
||||
verbose: bool = False,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
"""Export a YOLO model to TensorRT engine format.
|
||||
|
||||
Args:
|
||||
onnx_file (str): Path to the ONNX file to be converted.
|
||||
engine_file (str | None): Path to save the generated TensorRT engine file.
|
||||
workspace (int | None): Workspace size in GB for TensorRT.
|
||||
half (bool, optional): Enable FP16 precision.
|
||||
int8 (bool, optional): Enable INT8 precision.
|
||||
dynamic (bool, optional): Enable dynamic input shapes.
|
||||
shape (tuple[int, int, int, int], optional): Input shape (batch, channels, height, width).
|
||||
dla (int | None): DLA core to use (Jetson devices only).
|
||||
dataset (ultralytics.data.build.InfiniteDataLoader, optional): Dataset for INT8 calibration.
|
||||
metadata (dict | None): Metadata to include in the engine file.
|
||||
verbose (bool, optional): Enable verbose logging.
|
||||
prefix (str, optional): Prefix for log messages.
|
||||
|
||||
Raises:
|
||||
ValueError: If DLA is enabled on non-Jetson devices or required precision is not set.
|
||||
RuntimeError: If the ONNX file cannot be parsed.
|
||||
|
||||
Notes:
|
||||
TensorRT version compatibility is handled for workspace size and engine building.
|
||||
INT8 calibration requires a dataset and generates a calibration cache.
|
||||
Metadata is serialized and written to the engine file if provided.
|
||||
"""
|
||||
import tensorrt as trt
|
||||
|
||||
engine_file = engine_file or Path(onnx_file).with_suffix(".engine")
|
||||
|
||||
logger = trt.Logger(trt.Logger.INFO)
|
||||
if verbose:
|
||||
logger.min_severity = trt.Logger.Severity.VERBOSE
|
||||
|
||||
# Engine builder
|
||||
builder = trt.Builder(logger)
|
||||
config = builder.create_builder_config()
|
||||
workspace_bytes = int((workspace or 0) * (1 << 30))
|
||||
is_trt10 = int(trt.__version__.split(".", 1)[0]) >= 10 # is TensorRT >= 10
|
||||
if is_trt10 and workspace_bytes > 0:
|
||||
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, workspace_bytes)
|
||||
elif workspace_bytes > 0: # TensorRT versions 7, 8
|
||||
config.max_workspace_size = workspace_bytes
|
||||
flag = 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
|
||||
network = builder.create_network(flag)
|
||||
half = builder.platform_has_fast_fp16 and half
|
||||
int8 = builder.platform_has_fast_int8 and int8
|
||||
|
||||
# Optionally switch to DLA if enabled
|
||||
if dla is not None:
|
||||
if not IS_JETSON:
|
||||
raise ValueError("DLA is only available on NVIDIA Jetson devices")
|
||||
LOGGER.info(f"{prefix} enabling DLA on core {dla}...")
|
||||
if not half and not int8:
|
||||
raise ValueError(
|
||||
"DLA requires either 'half=True' (FP16) or 'int8=True' (INT8) to be enabled. Please enable one of them and try again."
|
||||
)
|
||||
config.default_device_type = trt.DeviceType.DLA
|
||||
config.DLA_core = int(dla)
|
||||
config.set_flag(trt.BuilderFlag.GPU_FALLBACK)
|
||||
|
||||
# Read ONNX file
|
||||
parser = trt.OnnxParser(network, logger)
|
||||
if not parser.parse_from_file(onnx_file):
|
||||
raise RuntimeError(f"failed to load ONNX file: {onnx_file}")
|
||||
|
||||
# Network inputs
|
||||
inputs = [network.get_input(i) for i in range(network.num_inputs)]
|
||||
outputs = [network.get_output(i) for i in range(network.num_outputs)]
|
||||
for inp in inputs:
|
||||
LOGGER.info(f'{prefix} input "{inp.name}" with shape{inp.shape} {inp.dtype}')
|
||||
for out in outputs:
|
||||
LOGGER.info(f'{prefix} output "{out.name}" with shape{out.shape} {out.dtype}')
|
||||
|
||||
if dynamic:
|
||||
profile = builder.create_optimization_profile()
|
||||
min_shape = (1, shape[1], 32, 32) # minimum input shape
|
||||
max_shape = (*shape[:2], *(int(max(2, workspace or 2) * d) for d in shape[2:])) # max input shape
|
||||
for inp in inputs:
|
||||
profile.set_shape(inp.name, min=min_shape, opt=shape, max=max_shape)
|
||||
config.add_optimization_profile(profile)
|
||||
if int8 and not is_trt10: # deprecated in TensorRT 10, causes internal errors
|
||||
config.set_calibration_profile(profile)
|
||||
|
||||
LOGGER.info(f"{prefix} building {'INT8' if int8 else 'FP' + ('16' if half else '32')} engine as {engine_file}")
|
||||
if int8:
|
||||
config.set_flag(trt.BuilderFlag.INT8)
|
||||
config.profiling_verbosity = trt.ProfilingVerbosity.DETAILED
|
||||
|
||||
class EngineCalibrator(trt.IInt8Calibrator):
|
||||
"""Custom INT8 calibrator for TensorRT engine optimization.
|
||||
|
||||
This calibrator provides the necessary interface for TensorRT to perform INT8 quantization calibration using
|
||||
a dataset. It handles batch generation, caching, and calibration algorithm selection.
|
||||
|
||||
Attributes:
|
||||
dataset: Dataset for calibration.
|
||||
data_iter: Iterator over the calibration dataset.
|
||||
algo (trt.CalibrationAlgoType): Calibration algorithm type.
|
||||
batch (int): Batch size for calibration.
|
||||
cache (Path): Path to save the calibration cache.
|
||||
|
||||
Methods:
|
||||
get_algorithm: Get the calibration algorithm to use.
|
||||
get_batch_size: Get the batch size to use for calibration.
|
||||
get_batch: Get the next batch to use for calibration.
|
||||
read_calibration_cache: Use existing cache instead of calibrating again.
|
||||
write_calibration_cache: Write calibration cache to disk.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dataset, # ultralytics.data.build.InfiniteDataLoader
|
||||
cache: str = "",
|
||||
) -> None:
|
||||
"""Initialize the INT8 calibrator with dataset and cache path."""
|
||||
trt.IInt8Calibrator.__init__(self)
|
||||
self.dataset = dataset
|
||||
self.data_iter = iter(dataset)
|
||||
self.algo = (
|
||||
trt.CalibrationAlgoType.ENTROPY_CALIBRATION_2 # DLA quantization needs ENTROPY_CALIBRATION_2
|
||||
if dla is not None
|
||||
else trt.CalibrationAlgoType.MINMAX_CALIBRATION
|
||||
)
|
||||
self.batch = dataset.batch_size
|
||||
self.cache = Path(cache)
|
||||
|
||||
def get_algorithm(self) -> trt.CalibrationAlgoType:
|
||||
"""Get the calibration algorithm to use."""
|
||||
return self.algo
|
||||
|
||||
def get_batch_size(self) -> int:
|
||||
"""Get the batch size to use for calibration."""
|
||||
return self.batch or 1
|
||||
|
||||
def get_batch(self, names) -> list[int] | None:
|
||||
"""Get the next batch to use for calibration, as a list of device memory pointers."""
|
||||
try:
|
||||
im0s = next(self.data_iter)["img"] / 255.0
|
||||
im0s = im0s.to("cuda") if im0s.device.type == "cpu" else im0s
|
||||
return [int(im0s.data_ptr())]
|
||||
except StopIteration:
|
||||
# Return None to signal to TensorRT there is no calibration data remaining
|
||||
return None
|
||||
|
||||
def read_calibration_cache(self) -> bytes | None:
|
||||
"""Use existing cache instead of calibrating again, otherwise, implicitly return None."""
|
||||
if self.cache.exists() and self.cache.suffix == ".cache":
|
||||
return self.cache.read_bytes()
|
||||
|
||||
def write_calibration_cache(self, cache: bytes) -> None:
|
||||
"""Write calibration cache to disk."""
|
||||
_ = self.cache.write_bytes(cache)
|
||||
|
||||
# Load dataset w/ builder (for batching) and calibrate
|
||||
config.int8_calibrator = EngineCalibrator(
|
||||
dataset=dataset,
|
||||
cache=str(Path(onnx_file).with_suffix(".cache")),
|
||||
)
|
||||
|
||||
elif half:
|
||||
config.set_flag(trt.BuilderFlag.FP16)
|
||||
|
||||
# Write file
|
||||
if is_trt10:
|
||||
# TensorRT 10+ returns bytes directly, not a context manager
|
||||
engine = builder.build_serialized_network(network, config)
|
||||
if engine is None:
|
||||
raise RuntimeError("TensorRT engine build failed, check logs for errors")
|
||||
with open(engine_file, "wb") as t:
|
||||
if metadata is not None:
|
||||
meta = json.dumps(metadata)
|
||||
t.write(len(meta).to_bytes(4, byteorder="little", signed=True))
|
||||
t.write(meta.encode())
|
||||
t.write(engine)
|
||||
else:
|
||||
with builder.build_engine(network, config) as engine, open(engine_file, "wb") as t:
|
||||
if metadata is not None:
|
||||
meta = json.dumps(metadata)
|
||||
t.write(len(meta).to_bytes(4, byteorder="little", signed=True))
|
||||
t.write(meta.encode())
|
||||
t.write(engine.serialize())
|
||||
79
ultralytics/utils/export/executorch.py
Executable file
79
ultralytics/utils/export/executorch.py
Executable file
@@ -0,0 +1,79 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
from ultralytics.nn.modules import Pose, Pose26
|
||||
from ultralytics.utils import LOGGER, YAML
|
||||
|
||||
|
||||
def executorch_wrapper(model: torch.nn.Module) -> torch.nn.Module:
|
||||
"""Apply ExecuTorch-specific model patches required for export/runtime compatibility."""
|
||||
import types
|
||||
|
||||
for m in model.modules():
|
||||
if not isinstance(m, Pose):
|
||||
continue
|
||||
m.kpts_decode = types.MethodType(partial(_executorch_kpts_decode, is_pose26=type(m) is Pose26), m)
|
||||
return model
|
||||
|
||||
|
||||
def _executorch_kpts_decode(self, kpts: torch.Tensor, is_pose26: bool = False) -> torch.Tensor:
|
||||
"""Decode pose keypoints for ExecuTorch export with XNNPACK-safe broadcasting."""
|
||||
ndim = self.kpt_shape[1]
|
||||
bs = kpts.shape[0]
|
||||
y = kpts.view(bs, *self.kpt_shape, -1)
|
||||
|
||||
# XNNPACK requires explicit dim matching for broadcasting, expand 2D tensors to 4D.
|
||||
anchors = self.anchors[None, None]
|
||||
strides = self.strides[None, None]
|
||||
a = ((y[:, :, :2] + anchors) if is_pose26 else (y[:, :, :2] * 2.0 + (anchors - 0.5))) * strides
|
||||
if ndim == 3:
|
||||
a = torch.cat((a, y[:, :, 2:3].sigmoid()), 2)
|
||||
return a.view(bs, self.nk, -1)
|
||||
|
||||
|
||||
def torch2executorch(
|
||||
model: torch.nn.Module,
|
||||
file: Path | str,
|
||||
sample_input: torch.Tensor,
|
||||
metadata: dict | None = None,
|
||||
prefix: str = "",
|
||||
) -> str:
|
||||
"""Export a PyTorch model to ExecuTorch format.
|
||||
|
||||
Args:
|
||||
model (torch.nn.Module): The PyTorch model to export.
|
||||
file (Path | str): Source model file path used to derive output names.
|
||||
sample_input (torch.Tensor): Example input tensor for tracing/export.
|
||||
metadata (dict | None, optional): Optional metadata to save as YAML.
|
||||
prefix (str, optional): Prefix for log messages.
|
||||
|
||||
Returns:
|
||||
(str): Path to the exported ExecuTorch model directory.
|
||||
"""
|
||||
from executorch import version as executorch_version
|
||||
from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner
|
||||
from executorch.exir import to_edge_transform_and_lower
|
||||
|
||||
LOGGER.info(f"\n{prefix} starting export with ExecuTorch {executorch_version.__version__}...")
|
||||
|
||||
file = Path(file)
|
||||
output_dir = Path(str(file).replace(file.suffix, "_executorch_model"))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pte_file = output_dir / file.with_suffix(".pte").name
|
||||
et_program = to_edge_transform_and_lower(
|
||||
torch.export.export(model, (sample_input,)),
|
||||
partitioner=[XnnpackPartitioner()],
|
||||
).to_executorch()
|
||||
pte_file.write_bytes(et_program.buffer)
|
||||
|
||||
if metadata is not None:
|
||||
YAML.save(output_dir / "metadata.yaml", metadata)
|
||||
|
||||
return str(output_dir)
|
||||
345
ultralytics/utils/export/imx.py
Executable file
345
ultralytics/utils/export/imx.py
Executable file
@@ -0,0 +1,345 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ultralytics.nn.modules import Detect, Pose, Segment
|
||||
from ultralytics.utils import LOGGER, WINDOWS
|
||||
from ultralytics.utils.patches import onnx_export_patch
|
||||
from ultralytics.utils.tal import make_anchors
|
||||
from ultralytics.utils.torch_utils import copy_attr
|
||||
|
||||
# Configuration for Model Compression Toolkit (MCT) quantization
|
||||
MCT_CONFIG = {
|
||||
"YOLO11": {
|
||||
"detect": {
|
||||
"layer_names": ["sub", "mul_2", "add_14", "cat_19"],
|
||||
"weights_memory": 2585350.2439,
|
||||
"n_layers": {238, 239},
|
||||
},
|
||||
"pose": {
|
||||
"layer_names": ["sub", "mul_2", "add_14", "cat_21", "cat_22", "mul_4", "add_15"],
|
||||
"weights_memory": 2437771.67,
|
||||
"n_layers": {257, 258},
|
||||
},
|
||||
"classify": {"layer_names": [], "weights_memory": np.inf, "n_layers": {112}},
|
||||
"segment": {
|
||||
"layer_names": ["sub", "mul_2", "add_14", "cat_21"],
|
||||
"weights_memory": 2466604.8,
|
||||
"n_layers": {265, 266},
|
||||
},
|
||||
},
|
||||
"YOLOv8": {
|
||||
"detect": {
|
||||
"layer_names": ["sub", "mul", "add_6", "cat_15"],
|
||||
"weights_memory": 2550540.8,
|
||||
"n_layers": {168, 169},
|
||||
},
|
||||
"pose": {
|
||||
"layer_names": ["add_7", "mul_2", "cat_17", "mul", "sub", "add_6", "cat_18"],
|
||||
"weights_memory": 2482451.85,
|
||||
"n_layers": {187, 188},
|
||||
},
|
||||
"classify": {"layer_names": [], "weights_memory": np.inf, "n_layers": {73}},
|
||||
"segment": {
|
||||
"layer_names": ["sub", "mul", "add_6", "cat_17"],
|
||||
"weights_memory": 2580060.0,
|
||||
"n_layers": {195, 196},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FXModel(torch.nn.Module):
|
||||
"""A custom model class for torch.fx compatibility.
|
||||
|
||||
This class extends `torch.nn.Module` and is designed to ensure compatibility with torch.fx for tracing and graph
|
||||
manipulation. It copies attributes from an existing model and explicitly sets the model attribute to ensure proper
|
||||
copying.
|
||||
|
||||
Attributes:
|
||||
model (nn.Module): The original model's layers.
|
||||
imgsz (tuple[int, int]): The input image size (height, width).
|
||||
"""
|
||||
|
||||
def __init__(self, model, imgsz=(640, 640)):
|
||||
"""Initialize the FXModel.
|
||||
|
||||
Args:
|
||||
model (nn.Module): The original model to wrap for torch.fx compatibility.
|
||||
imgsz (tuple[int, int]): The input image size (height, width). Default is (640, 640).
|
||||
"""
|
||||
super().__init__()
|
||||
copy_attr(self, model)
|
||||
# Explicitly set `model` since `copy_attr` somehow does not copy it.
|
||||
self.model = model.model
|
||||
self.imgsz = imgsz
|
||||
|
||||
def forward(self, x):
|
||||
"""Forward pass through the model.
|
||||
|
||||
This method performs the forward pass through the model, handling the dependencies between layers and saving
|
||||
intermediate outputs.
|
||||
|
||||
Args:
|
||||
x (torch.Tensor): The input tensor to the model.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): The output tensor from the model.
|
||||
"""
|
||||
y = [] # outputs
|
||||
for m in self.model:
|
||||
if m.f != -1: # if not from previous layer
|
||||
# from earlier layers
|
||||
x = y[m.f] if isinstance(m.f, int) else [x if j == -1 else y[j] for j in m.f]
|
||||
if isinstance(m, Detect):
|
||||
m._inference = types.MethodType(_inference, m) # bind method to Detect
|
||||
m.anchors, m.strides = (
|
||||
x.transpose(0, 1)
|
||||
for x in make_anchors(
|
||||
torch.cat([s / m.stride.unsqueeze(-1) for s in self.imgsz], dim=1), m.stride, 0.5
|
||||
)
|
||||
)
|
||||
if type(m) is Pose:
|
||||
m.forward = types.MethodType(pose_forward, m) # bind method to Pose
|
||||
if type(m) is Segment:
|
||||
m.forward = types.MethodType(segment_forward, m) # bind method to Segment
|
||||
x = m(x) # run
|
||||
y.append(x) # save output
|
||||
return x
|
||||
|
||||
|
||||
def _inference(self, x: dict[str, torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Decode boxes and cls scores for imx object detection."""
|
||||
dbox = self.decode_bboxes(self.dfl(x["boxes"]), self.anchors.unsqueeze(0)) * self.strides
|
||||
return dbox.transpose(1, 2), x["scores"].sigmoid().permute(0, 2, 1)
|
||||
|
||||
|
||||
def pose_forward(self, x: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Forward pass for imx pose estimation, including keypoint decoding."""
|
||||
bs = x[0].shape[0] # batch size
|
||||
nk_out = getattr(self, "nk_output", self.nk)
|
||||
kpt = torch.cat([self.cv4[i](x[i]).view(bs, nk_out, -1) for i in range(self.nl)], -1)
|
||||
|
||||
# If using Pose26 with 5 dims, convert to 3 dims for export
|
||||
if hasattr(self, "nk_output") and self.nk_output != self.nk:
|
||||
spatial = kpt.shape[-1]
|
||||
kpt = kpt.view(bs, self.kpt_shape[0], self.kpt_shape[1] + 2, spatial)
|
||||
kpt = kpt[:, :, :-2, :] # Remove sigma_x, sigma_y
|
||||
kpt = kpt.view(bs, self.nk, spatial)
|
||||
x = Detect.forward(self, x)
|
||||
pred_kpt = self.kpts_decode(kpt)
|
||||
return *x, pred_kpt.permute(0, 2, 1)
|
||||
|
||||
|
||||
def segment_forward(self, x: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Forward pass for imx segmentation."""
|
||||
p = self.proto(x[0]) # mask protos
|
||||
bs = p.shape[0] # batch size
|
||||
mc = torch.cat([self.cv4[i](x[i]).view(bs, self.nm, -1) for i in range(self.nl)], 2) # mask coefficients
|
||||
x = Detect.forward(self, x)
|
||||
return *x, mc.transpose(1, 2), p
|
||||
|
||||
|
||||
class NMSWrapper(torch.nn.Module):
|
||||
"""Wrap PyTorch Module with multiclass_nms layer from edge-mdt-cl."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: torch.nn.Module,
|
||||
score_threshold: float = 0.001,
|
||||
iou_threshold: float = 0.7,
|
||||
max_detections: int = 300,
|
||||
task: str = "detect",
|
||||
):
|
||||
"""Initialize NMSWrapper with PyTorch Module and NMS parameters.
|
||||
|
||||
Args:
|
||||
model (torch.nn.Module): Model instance.
|
||||
score_threshold (float): Score threshold for non-maximum suppression.
|
||||
iou_threshold (float): Intersection over union threshold for non-maximum suppression.
|
||||
max_detections (int): The number of detections to return.
|
||||
task (str): Task type, one of 'detect', 'pose', or 'segment'.
|
||||
"""
|
||||
super().__init__()
|
||||
self.model = model
|
||||
self.score_threshold = score_threshold
|
||||
self.iou_threshold = iou_threshold
|
||||
self.max_detections = max_detections
|
||||
self.task = task
|
||||
|
||||
def forward(self, images):
|
||||
"""Forward pass with model inference and NMS post-processing."""
|
||||
from edgemdt_cl.pytorch.nms.nms_with_indices import multiclass_nms_with_indices
|
||||
|
||||
# model inference
|
||||
outputs = self.model(images)
|
||||
boxes, scores = outputs[0], outputs[1]
|
||||
nms_outputs = multiclass_nms_with_indices(
|
||||
boxes=boxes,
|
||||
scores=scores,
|
||||
score_threshold=self.score_threshold,
|
||||
iou_threshold=self.iou_threshold,
|
||||
max_detections=self.max_detections,
|
||||
)
|
||||
if self.task == "pose":
|
||||
kpts = outputs[2] # (bs, max_detections, kpts 17*3)
|
||||
out_kpts = torch.gather(kpts, 1, nms_outputs.indices.unsqueeze(-1).expand(-1, -1, kpts.size(-1)))
|
||||
return nms_outputs.boxes, nms_outputs.scores, nms_outputs.labels, out_kpts
|
||||
if self.task == "segment":
|
||||
mc, proto = outputs[2], outputs[3]
|
||||
out_mc = torch.gather(mc, 1, nms_outputs.indices.unsqueeze(-1).expand(-1, -1, mc.size(-1)))
|
||||
return nms_outputs.boxes, nms_outputs.scores, nms_outputs.labels, out_mc, proto
|
||||
return nms_outputs.boxes, nms_outputs.scores, nms_outputs.labels, nms_outputs.n_valid
|
||||
|
||||
|
||||
def torch2imx(
|
||||
model: torch.nn.Module,
|
||||
file: Path | str,
|
||||
conf: float,
|
||||
iou: float,
|
||||
max_det: int,
|
||||
metadata: dict | None = None,
|
||||
gptq: bool = False,
|
||||
dataset=None,
|
||||
prefix: str = "",
|
||||
):
|
||||
"""Export YOLO model to IMX format for deployment on Sony IMX500 devices.
|
||||
|
||||
This function quantizes a YOLO model using Model Compression Toolkit (MCT) and exports it to IMX format compatible
|
||||
with Sony IMX500 edge devices. It supports both YOLOv8n and YOLO11n models for detection, segmentation, pose
|
||||
estimation, and classification tasks.
|
||||
|
||||
Args:
|
||||
model (torch.nn.Module): The YOLO model to export. Must be YOLOv8n or YOLO11n.
|
||||
file (Path | str): Output file path for the exported model.
|
||||
conf (float): Confidence threshold for NMS post-processing.
|
||||
iou (float): IoU threshold for NMS post-processing.
|
||||
max_det (int): Maximum number of detections to return.
|
||||
metadata (dict | None, optional): Metadata to embed in the ONNX model. Defaults to None.
|
||||
gptq (bool, optional): Whether to use Gradient-Based Post Training Quantization. If False, uses standard Post
|
||||
Training Quantization. Defaults to False.
|
||||
dataset (optional): Representative dataset for quantization calibration. Defaults to None.
|
||||
prefix (str, optional): Logging prefix string. Defaults to "".
|
||||
|
||||
Returns:
|
||||
(Path): Path to the exported IMX model directory.
|
||||
|
||||
Raises:
|
||||
ValueError: If the model is not a supported YOLOv8n or YOLO11n variant.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics import YOLO
|
||||
>>> model = YOLO("yolo11n.pt")
|
||||
>>> path = torch2imx(model, "model.imx", conf=0.25, iou=0.7, max_det=300)
|
||||
|
||||
Notes:
|
||||
- Requires model_compression_toolkit, onnx, edgemdt_tpc, and edge-mdt-cl packages
|
||||
- Only supports YOLOv8n and YOLO11n models (detection, segmentation, pose, and classification tasks)
|
||||
- Output includes quantized ONNX model, IMX binary, and labels.txt file
|
||||
"""
|
||||
import model_compression_toolkit as mct
|
||||
import onnx
|
||||
from edgemdt_tpc import get_target_platform_capabilities
|
||||
|
||||
LOGGER.info(f"\n{prefix} starting export with model_compression_toolkit {mct.__version__}...")
|
||||
|
||||
def representative_dataset_gen(dataloader=dataset):
|
||||
for batch in dataloader:
|
||||
img = batch["img"]
|
||||
img = img / 255.0
|
||||
yield [img]
|
||||
|
||||
# NOTE: need tpc_version to be "4.0" for IMX500 Pose estimation models
|
||||
tpc = get_target_platform_capabilities(tpc_version="4.0", device_type="imx500")
|
||||
|
||||
bit_cfg = mct.core.BitWidthConfig()
|
||||
mct_config = MCT_CONFIG["YOLO11" if "C2PSA" in model.__str__() else "YOLOv8"][model.task]
|
||||
|
||||
# Check if the model has the expected number of layers
|
||||
if len(list(model.modules())) not in mct_config["n_layers"]:
|
||||
raise ValueError("IMX export only supported for YOLOv8n and YOLO11n models.")
|
||||
|
||||
for layer_name in mct_config["layer_names"]:
|
||||
bit_cfg.set_manual_activation_bit_width([mct.core.common.network_editors.NodeNameFilter(layer_name)], 16)
|
||||
|
||||
config = mct.core.CoreConfig(
|
||||
mixed_precision_config=mct.core.MixedPrecisionQuantizationConfig(num_of_images=10),
|
||||
quantization_config=mct.core.QuantizationConfig(concat_threshold_update=True),
|
||||
bit_width_config=bit_cfg,
|
||||
)
|
||||
|
||||
resource_utilization = mct.core.ResourceUtilization(weights_memory=mct_config["weights_memory"])
|
||||
|
||||
quant_model = (
|
||||
mct.gptq.pytorch_gradient_post_training_quantization( # Perform Gradient-Based Post Training Quantization
|
||||
model=model,
|
||||
representative_data_gen=representative_dataset_gen,
|
||||
target_resource_utilization=resource_utilization,
|
||||
gptq_config=mct.gptq.get_pytorch_gptq_config(
|
||||
n_epochs=1000, use_hessian_based_weights=False, use_hessian_sample_attention=False
|
||||
),
|
||||
core_config=config,
|
||||
target_platform_capabilities=tpc,
|
||||
)[0]
|
||||
if gptq
|
||||
else mct.ptq.pytorch_post_training_quantization( # Perform post training quantization
|
||||
in_module=model,
|
||||
representative_data_gen=representative_dataset_gen,
|
||||
target_resource_utilization=resource_utilization,
|
||||
core_config=config,
|
||||
target_platform_capabilities=tpc,
|
||||
)[0]
|
||||
)
|
||||
|
||||
if model.task != "classify":
|
||||
quant_model = NMSWrapper(
|
||||
model=quant_model,
|
||||
score_threshold=conf or 0.001,
|
||||
iou_threshold=iou,
|
||||
max_detections=max_det,
|
||||
task=model.task,
|
||||
)
|
||||
|
||||
f = Path(str(file).replace(file.suffix, "_imx_model"))
|
||||
f.mkdir(exist_ok=True)
|
||||
onnx_model = f / Path(str(file.name).replace(file.suffix, "_imx.onnx")) # js dir
|
||||
|
||||
with onnx_export_patch():
|
||||
mct.exporter.pytorch_export_model(
|
||||
model=quant_model, save_model_path=onnx_model, repr_dataset=representative_dataset_gen
|
||||
)
|
||||
|
||||
model_onnx = onnx.load(onnx_model) # load onnx model
|
||||
for k, v in metadata.items():
|
||||
meta = model_onnx.metadata_props.add()
|
||||
meta.key, meta.value = k, str(v)
|
||||
|
||||
onnx.save(model_onnx, onnx_model)
|
||||
|
||||
# Find imxconv-pt binary - check venv bin directory first, then PATH
|
||||
bin_dir = Path(sys.executable).parent
|
||||
imxconv = bin_dir / ("imxconv-pt.exe" if WINDOWS else "imxconv-pt")
|
||||
if not imxconv.exists():
|
||||
imxconv = which("imxconv-pt") # fallback to PATH
|
||||
if not imxconv:
|
||||
raise FileNotFoundError("imxconv-pt not found. Install with: pip install imx500-converter[pt]")
|
||||
|
||||
subprocess.run(
|
||||
[str(imxconv), "-i", str(onnx_model), "-o", str(f), "--no-input-persistency", "--overwrite-output"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Needed for imx models.
|
||||
with open(f / "labels.txt", "w", encoding="utf-8") as file:
|
||||
file.writelines([f"{name}\n" for _, name in model.names.items()])
|
||||
|
||||
return f
|
||||
231
ultralytics/utils/export/tensorflow.py
Executable file
231
ultralytics/utils/export/tensorflow.py
Executable file
@@ -0,0 +1,231 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ultralytics.nn.modules import Detect, Pose, Pose26
|
||||
from ultralytics.utils import LOGGER
|
||||
from ultralytics.utils.downloads import attempt_download_asset
|
||||
from ultralytics.utils.files import spaces_in_path
|
||||
from ultralytics.utils.tal import make_anchors
|
||||
|
||||
|
||||
def tf_wrapper(model: torch.nn.Module) -> torch.nn.Module:
|
||||
"""A wrapper for TensorFlow export compatibility (TF-specific handling is now in head modules)."""
|
||||
for m in model.modules():
|
||||
if not isinstance(m, Detect):
|
||||
continue
|
||||
import types
|
||||
|
||||
m._get_decode_boxes = types.MethodType(_tf_decode_boxes, m)
|
||||
if isinstance(m, Pose):
|
||||
m.kpts_decode = types.MethodType(partial(_tf_kpts_decode, is_pose26=type(m) is Pose26), m)
|
||||
return model
|
||||
|
||||
|
||||
def _tf_decode_boxes(self, x: dict[str, torch.Tensor]) -> torch.Tensor:
|
||||
"""Decode bounding boxes for TensorFlow export."""
|
||||
shape = x["feats"][0].shape # BCHW
|
||||
boxes = x["boxes"]
|
||||
if self.format != "imx" and (self.dynamic or self.shape != shape):
|
||||
self.anchors, self.strides = (a.transpose(0, 1) for a in make_anchors(x["feats"], self.stride, 0.5))
|
||||
self.shape = shape
|
||||
grid_h, grid_w = shape[2:4]
|
||||
grid_size = torch.tensor([grid_w, grid_h, grid_w, grid_h], device=boxes.device).reshape(1, 4, 1)
|
||||
norm = self.strides / (self.stride[0] * grid_size)
|
||||
dbox = self.decode_bboxes(self.dfl(boxes) * norm, self.anchors.unsqueeze(0) * norm[:, :2])
|
||||
return dbox
|
||||
|
||||
|
||||
def _tf_kpts_decode(self, kpts: torch.Tensor, is_pose26: bool = False) -> torch.Tensor:
|
||||
"""Decode keypoints for TensorFlow export."""
|
||||
ndim = self.kpt_shape[1]
|
||||
bs = kpts.shape[0]
|
||||
# Precompute normalization factor to increase numerical stability
|
||||
y = kpts.view(bs, *self.kpt_shape, -1)
|
||||
grid_h, grid_w = self.shape[2:4]
|
||||
grid_size = torch.tensor([grid_w, grid_h], device=y.device).reshape(1, 2, 1)
|
||||
norm = self.strides / (self.stride[0] * grid_size)
|
||||
a = ((y[:, :, :2] + self.anchors) if is_pose26 else (y[:, :, :2] * 2.0 + (self.anchors - 0.5))) * norm
|
||||
if ndim == 3:
|
||||
a = torch.cat((a, y[:, :, 2:3].sigmoid()), 2)
|
||||
return a.view(bs, self.nk, -1)
|
||||
|
||||
|
||||
def onnx2saved_model(
|
||||
onnx_file: str,
|
||||
output_dir: Path,
|
||||
int8: bool = False,
|
||||
images: np.ndarray = None,
|
||||
disable_group_convolution: bool = False,
|
||||
prefix="",
|
||||
):
|
||||
"""Convert an ONNX model to TensorFlow SavedModel format using onnx2tf.
|
||||
|
||||
Args:
|
||||
onnx_file (str): ONNX file path.
|
||||
output_dir (Path): Output directory path for the SavedModel.
|
||||
int8 (bool, optional): Enable INT8 quantization. Defaults to False.
|
||||
images (np.ndarray, optional): Calibration images for INT8 quantization in BHWC format.
|
||||
disable_group_convolution (bool, optional): Disable group convolution optimization. Defaults to False.
|
||||
prefix (str, optional): Logging prefix. Defaults to "".
|
||||
|
||||
Returns:
|
||||
(keras.Model): Converted Keras model.
|
||||
|
||||
Notes:
|
||||
- Requires onnx2tf package. Downloads calibration data if INT8 quantization is enabled.
|
||||
- Removes temporary files and renames quantized models after conversion.
|
||||
"""
|
||||
# Pre-download calibration file to fix https://github.com/PINTO0309/onnx2tf/issues/545
|
||||
onnx2tf_file = Path("calibration_image_sample_data_20x128x128x3_float32.npy")
|
||||
if not onnx2tf_file.exists():
|
||||
attempt_download_asset(f"{onnx2tf_file}.zip", unzip=True, delete=True)
|
||||
np_data = None
|
||||
if int8:
|
||||
tmp_file = output_dir / "tmp_tflite_int8_calibration_images.npy" # int8 calibration images file
|
||||
if images is not None:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
np.save(str(tmp_file), images) # BHWC
|
||||
np_data = [["images", tmp_file, [[[[0, 0, 0]]]], [[[[255, 255, 255]]]]]]
|
||||
|
||||
# Patch onnx.helper for onnx_graphsurgeon compatibility with ONNX>=1.17
|
||||
# The float32_to_bfloat16 function was removed in ONNX 1.17, but onnx_graphsurgeon still uses it
|
||||
import onnx.helper
|
||||
|
||||
if not hasattr(onnx.helper, "float32_to_bfloat16"):
|
||||
import struct
|
||||
|
||||
def float32_to_bfloat16(fval):
|
||||
"""Convert float32 to bfloat16 (truncates lower 16 bits of mantissa)."""
|
||||
ival = struct.unpack("=I", struct.pack("=f", fval))[0]
|
||||
return ival >> 16
|
||||
|
||||
onnx.helper.float32_to_bfloat16 = float32_to_bfloat16
|
||||
|
||||
import onnx2tf # scoped for after ONNX export for reduced conflict during import
|
||||
|
||||
LOGGER.info(f"{prefix} starting TFLite export with onnx2tf {onnx2tf.__version__}...")
|
||||
keras_model = onnx2tf.convert(
|
||||
input_onnx_file_path=onnx_file,
|
||||
output_folder_path=str(output_dir),
|
||||
not_use_onnxsim=True,
|
||||
verbosity="error", # note INT8-FP16 activation bug https://github.com/ultralytics/ultralytics/issues/15873
|
||||
output_integer_quantized_tflite=int8,
|
||||
custom_input_op_name_np_data_path=np_data,
|
||||
enable_batchmatmul_unfold=True and not int8, # fix lower no. of detected objects on GPU delegate
|
||||
output_signaturedefs=True, # fix error with Attention block group convolution
|
||||
disable_group_convolution=disable_group_convolution, # fix error with group convolution
|
||||
)
|
||||
|
||||
# Remove/rename TFLite models
|
||||
if int8:
|
||||
tmp_file.unlink(missing_ok=True)
|
||||
for file in output_dir.rglob("*_dynamic_range_quant.tflite"):
|
||||
file.rename(file.with_name(file.stem.replace("_dynamic_range_quant", "_int8") + file.suffix))
|
||||
for file in output_dir.rglob("*_integer_quant_with_int16_act.tflite"):
|
||||
file.unlink() # delete extra fp16 activation TFLite files
|
||||
return keras_model
|
||||
|
||||
|
||||
def keras2pb(keras_model, file: Path, prefix=""):
|
||||
"""Convert a Keras model to TensorFlow GraphDef (.pb) format.
|
||||
|
||||
Args:
|
||||
keras_model (keras.Model): Keras model to convert to frozen graph format.
|
||||
file (Path): Output file path (suffix will be changed to .pb).
|
||||
prefix (str, optional): Logging prefix. Defaults to "".
|
||||
|
||||
Notes:
|
||||
Creates a frozen graph by converting variables to constants for inference optimization.
|
||||
"""
|
||||
import tensorflow as tf
|
||||
from tensorflow.python.framework.convert_to_constants import convert_variables_to_constants_v2
|
||||
|
||||
LOGGER.info(f"\n{prefix} starting export with tensorflow {tf.__version__}...")
|
||||
m = tf.function(lambda x: keras_model(x)) # full model
|
||||
m = m.get_concrete_function(tf.TensorSpec(keras_model.inputs[0].shape, keras_model.inputs[0].dtype))
|
||||
frozen_func = convert_variables_to_constants_v2(m)
|
||||
frozen_func.graph.as_graph_def()
|
||||
tf.io.write_graph(graph_or_graph_def=frozen_func.graph, logdir=str(file.parent), name=file.name, as_text=False)
|
||||
|
||||
|
||||
def tflite2edgetpu(tflite_file: str | Path, output_dir: str | Path, prefix: str = ""):
|
||||
"""Convert a TensorFlow Lite model to Edge TPU format using the Edge TPU compiler.
|
||||
|
||||
Args:
|
||||
tflite_file (str | Path): Path to the input TensorFlow Lite (.tflite) model file.
|
||||
output_dir (str | Path): Output directory path for the compiled Edge TPU model.
|
||||
prefix (str, optional): Logging prefix. Defaults to "".
|
||||
|
||||
Notes:
|
||||
Requires the Edge TPU compiler to be installed. The function compiles the TFLite model
|
||||
for optimal performance on Google's Edge TPU hardware accelerator.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
cmd = (
|
||||
"edgetpu_compiler "
|
||||
f'--out_dir "{output_dir}" '
|
||||
"--show_operations "
|
||||
"--search_delegate "
|
||||
"--delegate_search_step 30 "
|
||||
"--timeout_sec 180 "
|
||||
f'"{tflite_file}"'
|
||||
)
|
||||
LOGGER.info(f"{prefix} running '{cmd}'")
|
||||
subprocess.run(cmd, shell=True)
|
||||
|
||||
|
||||
def pb2tfjs(pb_file: str, output_dir: str, half: bool = False, int8: bool = False, prefix: str = ""):
|
||||
"""Convert a TensorFlow GraphDef (.pb) model to TensorFlow.js format.
|
||||
|
||||
Args:
|
||||
pb_file (str): Path to the input TensorFlow GraphDef (.pb) model file.
|
||||
output_dir (str): Output directory path for the converted TensorFlow.js model.
|
||||
half (bool, optional): Enable FP16 quantization. Defaults to False.
|
||||
int8 (bool, optional): Enable INT8 quantization. Defaults to False.
|
||||
prefix (str, optional): Logging prefix. Defaults to "".
|
||||
|
||||
Notes:
|
||||
Requires tensorflowjs package. Uses tensorflowjs_converter command-line tool for conversion.
|
||||
Handles spaces in file paths and warns if output directory contains spaces.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
import tensorflow as tf
|
||||
import tensorflowjs as tfjs
|
||||
|
||||
LOGGER.info(f"\n{prefix} starting export with tensorflowjs {tfjs.__version__}...")
|
||||
|
||||
gd = tf.Graph().as_graph_def() # TF GraphDef
|
||||
with open(pb_file, "rb") as file:
|
||||
gd.ParseFromString(file.read())
|
||||
outputs = ",".join(gd_outputs(gd))
|
||||
LOGGER.info(f"\n{prefix} output node names: {outputs}")
|
||||
|
||||
quantization = "--quantize_float16" if half else "--quantize_uint8" if int8 else ""
|
||||
with spaces_in_path(pb_file) as fpb_, spaces_in_path(output_dir) as f_: # exporter cannot handle spaces in paths
|
||||
cmd = (
|
||||
"tensorflowjs_converter "
|
||||
f'--input_format=tf_frozen_model {quantization} --output_node_names={outputs} "{fpb_}" "{f_}"'
|
||||
)
|
||||
LOGGER.info(f"{prefix} running '{cmd}'")
|
||||
subprocess.run(cmd, shell=True)
|
||||
|
||||
if " " in output_dir:
|
||||
LOGGER.warning(f"{prefix} your model may not work correctly with spaces in path '{output_dir}'.")
|
||||
|
||||
|
||||
def gd_outputs(gd):
|
||||
"""Return TensorFlow GraphDef model output node names."""
|
||||
name_list, input_list = [], []
|
||||
for node in gd.node: # tensorflow.core.framework.node_def_pb2.NodeDef
|
||||
name_list.append(node.name)
|
||||
input_list.extend(node.input)
|
||||
return sorted(f"{x}:0" for x in list(set(name_list) - set(input_list)) if not x.startswith("NoOp"))
|
||||
219
ultralytics/utils/files.py
Executable file
219
ultralytics/utils/files.py
Executable file
@@ -0,0 +1,219 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class WorkingDirectory(contextlib.ContextDecorator):
|
||||
"""A context manager and decorator for temporarily changing the working directory.
|
||||
|
||||
This class allows for the temporary change of the working directory using a context manager or decorator. It ensures
|
||||
that the original working directory is restored after the context or decorated function completes.
|
||||
|
||||
Attributes:
|
||||
dir (Path | str): The new directory to switch to.
|
||||
cwd (Path): The original current working directory before the switch.
|
||||
|
||||
Methods:
|
||||
__enter__: Changes the current directory to the specified directory.
|
||||
__exit__: Restores the original working directory on context exit.
|
||||
|
||||
Examples:
|
||||
Using as a context manager:
|
||||
>>> with WorkingDirectory("/path/to/new/dir"):
|
||||
... # Perform operations in the new directory
|
||||
... pass
|
||||
|
||||
Using as a decorator:
|
||||
>>> @WorkingDirectory("/path/to/new/dir")
|
||||
... def some_function():
|
||||
... # Perform operations in the new directory
|
||||
... pass
|
||||
"""
|
||||
|
||||
def __init__(self, new_dir: str | Path):
|
||||
"""Initialize the WorkingDirectory context manager with the target directory."""
|
||||
self.dir = new_dir # new dir
|
||||
self.cwd = Path.cwd().resolve() # current dir
|
||||
|
||||
def __enter__(self):
|
||||
"""Change the current working directory to the specified directory upon entering the context."""
|
||||
os.chdir(self.dir)
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Restore the original working directory when exiting the context."""
|
||||
os.chdir(self.cwd)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def spaces_in_path(path: str | Path):
|
||||
"""Context manager to handle paths with spaces in their names.
|
||||
|
||||
If a path contains spaces, it replaces them with underscores, copies the file/directory to the new path, executes
|
||||
the context code block, then copies the file/directory back to its original location.
|
||||
|
||||
Args:
|
||||
path (str | Path): The original path that may contain spaces.
|
||||
|
||||
Yields:
|
||||
(Path | str): Temporary path with any spaces replaced by underscores.
|
||||
|
||||
Examples:
|
||||
>>> with spaces_in_path("/path/with spaces") as new_path:
|
||||
... # Your code here
|
||||
... pass
|
||||
"""
|
||||
# If path has spaces, replace them with underscores
|
||||
if " " in str(path):
|
||||
string = isinstance(path, str) # input type
|
||||
path = Path(path)
|
||||
|
||||
# Create a temporary directory and construct the new path
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir) / path.name.replace(" ", "_")
|
||||
|
||||
# Copy file/directory
|
||||
if path.is_dir():
|
||||
shutil.copytree(path, tmp_path)
|
||||
elif path.is_file():
|
||||
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, tmp_path)
|
||||
|
||||
try:
|
||||
# Yield the temporary path
|
||||
yield str(tmp_path) if string else tmp_path
|
||||
|
||||
finally:
|
||||
# Copy file/directory back
|
||||
if tmp_path.is_dir():
|
||||
shutil.copytree(tmp_path, path, dirs_exist_ok=True)
|
||||
elif tmp_path.is_file():
|
||||
shutil.copy2(tmp_path, path) # Copy back the file
|
||||
|
||||
else:
|
||||
# If there are no spaces, just yield the original path
|
||||
yield path
|
||||
|
||||
|
||||
def increment_path(path: str | Path, exist_ok: bool = False, sep: str = "", mkdir: bool = False) -> Path:
|
||||
"""Increment a file or directory path, i.e., runs/exp --> runs/exp{sep}2, runs/exp{sep}3, ... etc.
|
||||
|
||||
If the path exists and `exist_ok` is not True, the path will be incremented by appending a number and `sep` to the
|
||||
end of the path. If the path is a file, the file extension will be preserved. If the path is a directory, the number
|
||||
will be appended directly to the end of the path.
|
||||
|
||||
Args:
|
||||
path (str | Path): Path to increment.
|
||||
exist_ok (bool, optional): If True, the path will not be incremented and returned as-is.
|
||||
sep (str, optional): Separator to use between the path and the incrementation number.
|
||||
mkdir (bool, optional): Create a directory if it does not exist.
|
||||
|
||||
Returns:
|
||||
(Path): Incremented path.
|
||||
|
||||
Examples:
|
||||
Increment a directory path:
|
||||
>>> from pathlib import Path
|
||||
>>> path = Path("runs/exp")
|
||||
>>> new_path = increment_path(path)
|
||||
>>> print(new_path)
|
||||
runs/exp2
|
||||
|
||||
Increment a file path:
|
||||
>>> path = Path("runs/exp/results.txt")
|
||||
>>> new_path = increment_path(path)
|
||||
>>> print(new_path)
|
||||
runs/exp/results2.txt
|
||||
"""
|
||||
path = Path(path) # os-agnostic
|
||||
if path.exists() and not exist_ok:
|
||||
path, suffix = (path.with_suffix(""), path.suffix) if path.is_file() else (path, "")
|
||||
|
||||
# Method 1
|
||||
for n in range(2, 9999):
|
||||
p = f"{path}{sep}{n}{suffix}" # increment path
|
||||
if not os.path.exists(p):
|
||||
break
|
||||
path = Path(p)
|
||||
|
||||
if mkdir:
|
||||
path.mkdir(parents=True, exist_ok=True) # make directory
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def file_age(path: str | Path = __file__) -> int:
|
||||
"""Return days since the last modification of the specified file."""
|
||||
dt = datetime.now() - datetime.fromtimestamp(Path(path).stat().st_mtime) # delta
|
||||
return dt.days # + dt.seconds / 86400 # fractional days
|
||||
|
||||
|
||||
def file_date(path: str | Path = __file__) -> str:
|
||||
"""Return the file modification date in 'YYYY-M-D' format."""
|
||||
t = datetime.fromtimestamp(Path(path).stat().st_mtime)
|
||||
return f"{t.year}-{t.month}-{t.day}"
|
||||
|
||||
|
||||
def file_size(path: str | Path) -> float:
|
||||
"""Return the size of a file or directory in mebibytes (MiB)."""
|
||||
if isinstance(path, (str, Path)):
|
||||
mb = 1 << 20 # bytes to MiB (1024 ** 2)
|
||||
path = Path(path)
|
||||
if path.is_file():
|
||||
return path.stat().st_size / mb
|
||||
elif path.is_dir():
|
||||
return sum(f.stat().st_size for f in path.glob("**/*") if f.is_file()) / mb
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_latest_run(search_dir: str = ".") -> str:
|
||||
"""Return the path to the most recent 'last.pt' file in the specified directory for resuming training."""
|
||||
last_list = glob.glob(f"{search_dir}/**/last*.pt", recursive=True)
|
||||
return max(last_list, key=os.path.getctime) if last_list else ""
|
||||
|
||||
|
||||
def update_models(model_names: tuple = ("yolo26n.pt",), source_dir: Path = Path("."), update_names: bool = False):
|
||||
"""Update and re-save specified YOLO models in an 'updated_models' subdirectory.
|
||||
|
||||
Args:
|
||||
model_names (tuple, optional): Model filenames to update.
|
||||
source_dir (Path, optional): Directory containing models and target subdirectory.
|
||||
update_names (bool, optional): Update model names from a data YAML.
|
||||
|
||||
Examples:
|
||||
Update specified YOLO models and save them in 'updated_models' subdirectory:
|
||||
>>> from ultralytics.utils.files import update_models
|
||||
>>> model_names = ("yolo26n.pt", "yolo11s.pt")
|
||||
>>> update_models(model_names, source_dir=Path("/models"), update_names=True)
|
||||
"""
|
||||
from ultralytics import YOLO
|
||||
from ultralytics.nn.autobackend import default_class_names
|
||||
from ultralytics.utils import LOGGER
|
||||
|
||||
target_dir = source_dir / "updated_models"
|
||||
target_dir.mkdir(parents=True, exist_ok=True) # Ensure target directory exists
|
||||
|
||||
for model_name in model_names:
|
||||
model_path = source_dir / model_name
|
||||
LOGGER.info(f"Loading model from {model_path}")
|
||||
|
||||
# Load model
|
||||
model = YOLO(model_path)
|
||||
model.half()
|
||||
if update_names: # update model names from a dataset YAML
|
||||
model.model.names = default_class_names("coco8.yaml")
|
||||
|
||||
# Define new save path
|
||||
save_path = target_dir / model_name
|
||||
|
||||
# Save model using model.save()
|
||||
LOGGER.info(f"Re-saving {model_name} model to {save_path}")
|
||||
model.save(save_path)
|
||||
137
ultralytics/utils/git.py
Executable file
137
ultralytics/utils/git.py
Executable file
@@ -0,0 +1,137 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class GitRepo:
|
||||
"""Represent a local Git repository and expose branch, commit, and remote metadata.
|
||||
|
||||
This class discovers the repository root by searching for a .git entry from the given path upward, resolves the
|
||||
actual .git directory (including worktrees), and reads Git metadata directly from on-disk files. It does not invoke
|
||||
the git binary and therefore works in restricted environments. All metadata properties are resolved lazily and
|
||||
cached; construct a new instance to refresh state.
|
||||
|
||||
Attributes:
|
||||
root (Path | None): Repository root directory containing the .git entry; None if not in a repository.
|
||||
gitdir (Path | None): Resolved .git directory path; handles worktrees; None if unresolved.
|
||||
head (str | None): Raw contents of HEAD; a SHA for detached HEAD or "ref: <refname>" for branch heads.
|
||||
is_repo (bool): Whether the provided path resides inside a Git repository.
|
||||
branch (str | None): Current branch name when HEAD points to a branch; None for detached HEAD or non-repo.
|
||||
commit (str | None): Current commit SHA for HEAD; None if not determinable.
|
||||
origin (str | None): URL of the "origin" remote as read from gitdir/config; None if unset or unavailable.
|
||||
|
||||
Examples:
|
||||
Initialize from the current working directory and read metadata
|
||||
>>> from pathlib import Path
|
||||
>>> repo = GitRepo(Path.cwd())
|
||||
>>> repo.is_repo
|
||||
True
|
||||
>>> repo.branch, repo.commit[:7], repo.origin
|
||||
('main', '1a2b3c4', 'https://example.com/owner/repo.git')
|
||||
|
||||
Notes:
|
||||
- Resolves metadata by reading files: HEAD, packed-refs, and config; no subprocess calls are used.
|
||||
- Caches properties on first access using cached_property; recreate the object to reflect repository changes.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path = Path(__file__).resolve()):
|
||||
"""Initialize a Git repository context by discovering the repository root from a starting path.
|
||||
|
||||
Args:
|
||||
path (Path, optional): File or directory path used as the starting point to locate the repository root.
|
||||
"""
|
||||
self.root = self._find_root(path)
|
||||
self.gitdir = self._gitdir(self.root) if self.root else None
|
||||
|
||||
@staticmethod
|
||||
def _find_root(p: Path) -> Path | None:
|
||||
"""Return repo root or None."""
|
||||
return next((d for d in [p, *list(p.parents)] if (d / ".git").exists()), None)
|
||||
|
||||
@staticmethod
|
||||
def _gitdir(root: Path) -> Path | None:
|
||||
"""Resolve actual .git directory (handles worktrees)."""
|
||||
g = root / ".git"
|
||||
if g.is_dir():
|
||||
return g
|
||||
if g.is_file():
|
||||
t = g.read_text(errors="ignore").strip()
|
||||
if t.startswith("gitdir:"):
|
||||
return (root / t.split(":", 1)[1].strip()).resolve()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _read(p: Path | None) -> str | None:
|
||||
"""Read and strip file if exists."""
|
||||
return p.read_text(errors="ignore").strip() if p and p.exists() else None
|
||||
|
||||
@cached_property
|
||||
def head(self) -> str | None:
|
||||
"""HEAD file contents."""
|
||||
return self._read(self.gitdir / "HEAD" if self.gitdir else None)
|
||||
|
||||
def _ref_commit(self, ref: str) -> str | None:
|
||||
"""Commit for ref (handles packed-refs)."""
|
||||
rf = self.gitdir / ref
|
||||
if s := self._read(rf):
|
||||
return s
|
||||
pf = self.gitdir / "packed-refs"
|
||||
b = pf.read_bytes().splitlines() if pf.exists() else []
|
||||
tgt = ref.encode()
|
||||
for line in b:
|
||||
if line[:1] in (b"#", b"^") or b" " not in line:
|
||||
continue
|
||||
sha, name = line.split(b" ", 1)
|
||||
if name.strip() == tgt:
|
||||
return sha.decode()
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_repo(self) -> bool:
|
||||
"""True if inside a git repo."""
|
||||
return self.gitdir is not None
|
||||
|
||||
@cached_property
|
||||
def branch(self) -> str | None:
|
||||
"""Current branch or None."""
|
||||
if not self.is_repo or not self.head or not self.head.startswith("ref: "):
|
||||
return None
|
||||
ref = self.head[5:].strip()
|
||||
return ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref
|
||||
|
||||
@cached_property
|
||||
def commit(self) -> str | None:
|
||||
"""Current commit SHA or None."""
|
||||
if not self.is_repo or not self.head:
|
||||
return None
|
||||
return self._ref_commit(self.head[5:].strip()) if self.head.startswith("ref: ") else self.head
|
||||
|
||||
@cached_property
|
||||
def origin(self) -> str | None:
|
||||
"""Origin URL or None."""
|
||||
if not self.is_repo:
|
||||
return None
|
||||
cfg = self.gitdir / "config"
|
||||
remote, url = None, None
|
||||
for s in (self._read(cfg) or "").splitlines():
|
||||
t = s.strip()
|
||||
if t.startswith("[") and t.endswith("]"):
|
||||
remote = t.lower()
|
||||
elif t.lower().startswith("url =") and remote == '[remote "origin"]':
|
||||
url = t.split("=", 1)[1].strip()
|
||||
break
|
||||
return url
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
|
||||
g = GitRepo()
|
||||
if g.is_repo:
|
||||
t0 = time.perf_counter()
|
||||
print(f"repo={g.root}\nbranch={g.branch}\ncommit={g.commit}\norigin={g.origin}")
|
||||
dt = (time.perf_counter() - t0) * 1000
|
||||
print(f"\n⏱️ Profiling: total {dt:.3f} ms")
|
||||
536
ultralytics/utils/instance.py
Executable file
536
ultralytics/utils/instance.py
Executable file
@@ -0,0 +1,536 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import abc
|
||||
from itertools import repeat
|
||||
from numbers import Number
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .ops import ltwh2xywh, ltwh2xyxy, resample_segments, xywh2ltwh, xywh2xyxy, xyxy2ltwh, xyxy2xywh
|
||||
|
||||
|
||||
def _ntuple(n):
|
||||
"""Create a function that converts input to n-tuple by repeating singleton values."""
|
||||
|
||||
def parse(x):
|
||||
"""Parse input to return n-tuple by repeating singleton values n times."""
|
||||
return x if isinstance(x, abc.Iterable) else tuple(repeat(x, n))
|
||||
|
||||
return parse
|
||||
|
||||
|
||||
to_2tuple = _ntuple(2)
|
||||
to_4tuple = _ntuple(4)
|
||||
|
||||
# `xyxy` means left top and right bottom
|
||||
# `xywh` means center x, center y and width, height(YOLO format)
|
||||
# `ltwh` means left top and width, height(COCO format)
|
||||
_formats = ["xyxy", "xywh", "ltwh"]
|
||||
|
||||
__all__ = ("Bboxes", "Instances") # tuple or list
|
||||
|
||||
|
||||
class Bboxes:
|
||||
"""A class for handling bounding boxes in multiple formats.
|
||||
|
||||
The class supports various bounding box formats like 'xyxy', 'xywh', and 'ltwh' and provides methods for format
|
||||
conversion, scaling, and area calculation. Bounding box data should be provided as numpy arrays.
|
||||
|
||||
Attributes:
|
||||
bboxes (np.ndarray): The bounding boxes stored in a 2D numpy array with shape (N, 4).
|
||||
format (str): The format of the bounding boxes ('xyxy', 'xywh', or 'ltwh').
|
||||
|
||||
Methods:
|
||||
convert: Convert bounding box format from one type to another.
|
||||
areas: Calculate the area of bounding boxes.
|
||||
mul: Multiply bounding box coordinates by scale factor(s).
|
||||
add: Add offset to bounding box coordinates.
|
||||
concatenate: Concatenate multiple Bboxes objects.
|
||||
|
||||
Examples:
|
||||
Create bounding boxes in YOLO format
|
||||
>>> bboxes = Bboxes(np.array([[100, 50, 150, 100]]), format="xywh")
|
||||
>>> bboxes.convert("xyxy")
|
||||
>>> print(bboxes.areas())
|
||||
|
||||
Notes:
|
||||
This class does not handle normalization or denormalization of bounding boxes.
|
||||
"""
|
||||
|
||||
def __init__(self, bboxes: np.ndarray, format: str = "xyxy") -> None:
|
||||
"""Initialize the Bboxes class with bounding box data in a specified format.
|
||||
|
||||
Args:
|
||||
bboxes (np.ndarray): Array of bounding boxes with shape (N, 4) or (4,).
|
||||
format (str): Format of the bounding boxes, one of 'xyxy', 'xywh', or 'ltwh'.
|
||||
"""
|
||||
assert format in _formats, f"Invalid bounding box format: {format}, format must be one of {_formats}"
|
||||
bboxes = bboxes[None, :] if bboxes.ndim == 1 else bboxes
|
||||
assert bboxes.ndim == 2
|
||||
assert bboxes.shape[1] == 4
|
||||
self.bboxes = bboxes
|
||||
self.format = format
|
||||
|
||||
def convert(self, format: str) -> None:
|
||||
"""Convert bounding box format from one type to another.
|
||||
|
||||
Args:
|
||||
format (str): Target format for conversion, one of 'xyxy', 'xywh', or 'ltwh'.
|
||||
"""
|
||||
assert format in _formats, f"Invalid bounding box format: {format}, format must be one of {_formats}"
|
||||
if self.format == format:
|
||||
return
|
||||
elif self.format == "xyxy":
|
||||
func = xyxy2xywh if format == "xywh" else xyxy2ltwh
|
||||
elif self.format == "xywh":
|
||||
func = xywh2xyxy if format == "xyxy" else xywh2ltwh
|
||||
else:
|
||||
func = ltwh2xyxy if format == "xyxy" else ltwh2xywh
|
||||
self.bboxes = func(self.bboxes)
|
||||
self.format = format
|
||||
|
||||
def areas(self) -> np.ndarray:
|
||||
"""Calculate the area of bounding boxes."""
|
||||
return (
|
||||
(self.bboxes[:, 2] - self.bboxes[:, 0]) * (self.bboxes[:, 3] - self.bboxes[:, 1]) # format xyxy
|
||||
if self.format == "xyxy"
|
||||
else self.bboxes[:, 3] * self.bboxes[:, 2] # format xywh or ltwh
|
||||
)
|
||||
|
||||
def mul(self, scale: int | tuple | list) -> None:
|
||||
"""Multiply bounding box coordinates by scale factor(s).
|
||||
|
||||
Args:
|
||||
scale (int | tuple | list): Scale factor(s) for four coordinates. If int, the same scale is applied to all
|
||||
coordinates.
|
||||
"""
|
||||
if isinstance(scale, Number):
|
||||
scale = to_4tuple(scale)
|
||||
assert isinstance(scale, (tuple, list))
|
||||
assert len(scale) == 4
|
||||
self.bboxes[:, 0] *= scale[0]
|
||||
self.bboxes[:, 1] *= scale[1]
|
||||
self.bboxes[:, 2] *= scale[2]
|
||||
self.bboxes[:, 3] *= scale[3]
|
||||
|
||||
def add(self, offset: int | tuple | list) -> None:
|
||||
"""Add offset to bounding box coordinates.
|
||||
|
||||
Args:
|
||||
offset (int | tuple | list): Offset(s) for four coordinates. If int, the same offset is applied to all
|
||||
coordinates.
|
||||
"""
|
||||
if isinstance(offset, Number):
|
||||
offset = to_4tuple(offset)
|
||||
assert isinstance(offset, (tuple, list))
|
||||
assert len(offset) == 4
|
||||
self.bboxes[:, 0] += offset[0]
|
||||
self.bboxes[:, 1] += offset[1]
|
||||
self.bboxes[:, 2] += offset[2]
|
||||
self.bboxes[:, 3] += offset[3]
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of bounding boxes."""
|
||||
return len(self.bboxes)
|
||||
|
||||
@classmethod
|
||||
def concatenate(cls, boxes_list: list[Bboxes], axis: int = 0) -> Bboxes:
|
||||
"""Concatenate a list of Bboxes objects into a single Bboxes object.
|
||||
|
||||
Args:
|
||||
boxes_list (list[Bboxes]): A list of Bboxes objects to concatenate.
|
||||
axis (int, optional): The axis along which to concatenate the bounding boxes.
|
||||
|
||||
Returns:
|
||||
(Bboxes): A new Bboxes object containing the concatenated bounding boxes.
|
||||
|
||||
Notes:
|
||||
The input should be a list or tuple of Bboxes objects.
|
||||
"""
|
||||
assert isinstance(boxes_list, (list, tuple))
|
||||
if not boxes_list:
|
||||
return cls(np.empty(0))
|
||||
assert all(isinstance(box, Bboxes) for box in boxes_list)
|
||||
|
||||
if len(boxes_list) == 1:
|
||||
return boxes_list[0]
|
||||
return cls(np.concatenate([b.bboxes for b in boxes_list], axis=axis))
|
||||
|
||||
def __getitem__(self, index: int | np.ndarray | slice) -> Bboxes:
|
||||
"""Retrieve a specific bounding box or a set of bounding boxes using indexing.
|
||||
|
||||
Args:
|
||||
index (int | slice | np.ndarray): The index, slice, or boolean array to select the desired bounding boxes.
|
||||
|
||||
Returns:
|
||||
(Bboxes): A new Bboxes object containing the selected bounding boxes.
|
||||
|
||||
Notes:
|
||||
When using boolean indexing, make sure to provide a boolean array with the same length as the number of
|
||||
bounding boxes.
|
||||
"""
|
||||
if isinstance(index, int):
|
||||
return Bboxes(self.bboxes[index].reshape(1, -1))
|
||||
b = self.bboxes[index]
|
||||
assert b.ndim == 2, f"Indexing on Bboxes with {index} failed to return a matrix!"
|
||||
return Bboxes(b)
|
||||
|
||||
|
||||
class Instances:
|
||||
"""Container for bounding boxes, segments, and keypoints of detected objects in an image.
|
||||
|
||||
This class provides a unified interface for handling different types of object annotations including bounding boxes,
|
||||
segmentation masks, and keypoints. It supports various operations like scaling, normalization, clipping, and format
|
||||
conversion.
|
||||
|
||||
Attributes:
|
||||
_bboxes (Bboxes): Internal object for handling bounding box operations.
|
||||
keypoints (np.ndarray): Keypoints with shape (N, 17, 3) in format (x, y, visible).
|
||||
normalized (bool): Flag indicating whether the bounding box coordinates are normalized.
|
||||
segments (np.ndarray): Segments array with shape (N, M, 2) after resampling.
|
||||
|
||||
Methods:
|
||||
convert_bbox: Convert bounding box format.
|
||||
scale: Scale coordinates by given factors.
|
||||
denormalize: Convert normalized coordinates to absolute coordinates.
|
||||
normalize: Convert absolute coordinates to normalized coordinates.
|
||||
add_padding: Add padding to coordinates.
|
||||
flipud: Flip coordinates vertically.
|
||||
fliplr: Flip coordinates horizontally.
|
||||
clip: Clip coordinates to stay within image boundaries.
|
||||
remove_zero_area_boxes: Remove boxes with zero area.
|
||||
update: Update instance variables.
|
||||
concatenate: Concatenate multiple Instances objects.
|
||||
|
||||
Examples:
|
||||
Create instances with bounding boxes and segments
|
||||
>>> instances = Instances(
|
||||
... bboxes=np.array([[10, 10, 30, 30], [20, 20, 40, 40]]),
|
||||
... segments=[np.array([[5, 5], [10, 10]]), np.array([[15, 15], [20, 20]])],
|
||||
... keypoints=np.array([[[5, 5, 1], [10, 10, 1]], [[15, 15, 1], [20, 20, 1]]]),
|
||||
... )
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bboxes: np.ndarray,
|
||||
segments: np.ndarray = None,
|
||||
keypoints: np.ndarray = None,
|
||||
bbox_format: str = "xywh",
|
||||
normalized: bool = True,
|
||||
difficulties: np.ndarray = None,
|
||||
difficulty_levels: np.ndarray = None,
|
||||
) -> None:
|
||||
"""Initialize the Instances object with bounding boxes, segments, and keypoints.
|
||||
|
||||
Args:
|
||||
bboxes (np.ndarray): Bounding boxes with shape (N, 4).
|
||||
segments (np.ndarray, optional): Segmentation masks.
|
||||
keypoints (np.ndarray, optional): Keypoints with shape (N, 17, 3) in format (x, y, visible).
|
||||
bbox_format (str): Format of bboxes.
|
||||
normalized (bool): Whether the coordinates are normalized.
|
||||
difficulties (np.ndarray, optional): Difficulty levels with shape (N,) for ground detection.
|
||||
"""
|
||||
self._bboxes = Bboxes(bboxes=bboxes, format=bbox_format)
|
||||
self.keypoints = keypoints
|
||||
self.normalized = normalized
|
||||
self.segments = segments
|
||||
self.difficulties = difficulties
|
||||
self.difficulty_levels = difficulty_levels
|
||||
|
||||
def convert_bbox(self, format: str) -> None:
|
||||
"""Convert bounding box format.
|
||||
|
||||
Args:
|
||||
format (str): Target format for conversion, one of 'xyxy', 'xywh', or 'ltwh'.
|
||||
"""
|
||||
self._bboxes.convert(format=format)
|
||||
|
||||
@property
|
||||
def bbox_areas(self) -> np.ndarray:
|
||||
"""Calculate the area of bounding boxes."""
|
||||
return self._bboxes.areas()
|
||||
|
||||
def scale(self, scale_w: float, scale_h: float, bbox_only: bool = False):
|
||||
"""Scale coordinates by given factors.
|
||||
|
||||
Args:
|
||||
scale_w (float): Scale factor for width.
|
||||
scale_h (float): Scale factor for height.
|
||||
bbox_only (bool, optional): Whether to scale only bounding boxes.
|
||||
"""
|
||||
self._bboxes.mul(scale=(scale_w, scale_h, scale_w, scale_h))
|
||||
if bbox_only:
|
||||
return
|
||||
self.segments[..., 0] *= scale_w
|
||||
self.segments[..., 1] *= scale_h
|
||||
if self.keypoints is not None:
|
||||
self.keypoints[..., 0] *= scale_w
|
||||
self.keypoints[..., 1] *= scale_h
|
||||
|
||||
def denormalize(self, w: int, h: int) -> None:
|
||||
"""Convert normalized coordinates to absolute coordinates.
|
||||
|
||||
Args:
|
||||
w (int): Image width.
|
||||
h (int): Image height.
|
||||
"""
|
||||
if not self.normalized:
|
||||
return
|
||||
self._bboxes.mul(scale=(w, h, w, h))
|
||||
self.segments[..., 0] *= w
|
||||
self.segments[..., 1] *= h
|
||||
if self.keypoints is not None:
|
||||
self.keypoints[..., 0] *= w
|
||||
self.keypoints[..., 1] *= h
|
||||
self.normalized = False
|
||||
|
||||
def normalize(self, w: int, h: int) -> None:
|
||||
"""Convert absolute coordinates to normalized coordinates.
|
||||
|
||||
Args:
|
||||
w (int): Image width.
|
||||
h (int): Image height.
|
||||
"""
|
||||
if self.normalized:
|
||||
return
|
||||
self._bboxes.mul(scale=(1 / w, 1 / h, 1 / w, 1 / h))
|
||||
self.segments[..., 0] /= w
|
||||
self.segments[..., 1] /= h
|
||||
if self.keypoints is not None:
|
||||
self.keypoints[..., 0] /= w
|
||||
self.keypoints[..., 1] /= h
|
||||
self.normalized = True
|
||||
|
||||
def add_padding(self, padw: int, padh: int) -> None:
|
||||
"""Add padding to coordinates.
|
||||
|
||||
Args:
|
||||
padw (int): Padding width.
|
||||
padh (int): Padding height.
|
||||
"""
|
||||
assert not self.normalized, "you should add padding with absolute coordinates."
|
||||
self._bboxes.add(offset=(padw, padh, padw, padh))
|
||||
self.segments[..., 0] += padw
|
||||
self.segments[..., 1] += padh
|
||||
if self.keypoints is not None:
|
||||
self.keypoints[..., 0] += padw
|
||||
self.keypoints[..., 1] += padh
|
||||
|
||||
def __getitem__(self, index: int | np.ndarray | slice) -> Instances:
|
||||
"""Retrieve a specific instance or a set of instances using indexing.
|
||||
|
||||
Args:
|
||||
index (int | slice | np.ndarray): The index, slice, or boolean array to select the desired instances.
|
||||
|
||||
Returns:
|
||||
(Instances): A new Instances object containing the selected boxes, segments, and keypoints if present.
|
||||
|
||||
Notes:
|
||||
When using boolean indexing, make sure to provide a boolean array with the same length as the number of
|
||||
instances.
|
||||
"""
|
||||
segments = self.segments[index] if len(self.segments) else self.segments
|
||||
keypoints = self.keypoints[index] if self.keypoints is not None else None
|
||||
difficulties = self.difficulties[index] if self.difficulties is not None else None
|
||||
difficulty_levels = self.difficulty_levels[index] if self.difficulty_levels is not None else None
|
||||
bboxes = self.bboxes[index]
|
||||
bbox_format = self._bboxes.format
|
||||
return Instances(
|
||||
bboxes=bboxes,
|
||||
segments=segments,
|
||||
keypoints=keypoints,
|
||||
bbox_format=bbox_format,
|
||||
normalized=self.normalized,
|
||||
difficulties=difficulties,
|
||||
difficulty_levels=difficulty_levels,
|
||||
)
|
||||
|
||||
def flipud(self, h: int) -> None:
|
||||
"""Flip coordinates vertically.
|
||||
|
||||
Args:
|
||||
h (int): Image height.
|
||||
"""
|
||||
if self._bboxes.format == "xyxy":
|
||||
y1 = self.bboxes[:, 1].copy()
|
||||
y2 = self.bboxes[:, 3].copy()
|
||||
self.bboxes[:, 1] = h - y2
|
||||
self.bboxes[:, 3] = h - y1
|
||||
else:
|
||||
self.bboxes[:, 1] = h - self.bboxes[:, 1]
|
||||
self.segments[..., 1] = h - self.segments[..., 1]
|
||||
if self.keypoints is not None:
|
||||
self.keypoints[..., 1] = h - self.keypoints[..., 1]
|
||||
|
||||
def fliplr(self, w: int) -> None:
|
||||
"""Flip coordinates horizontally.
|
||||
|
||||
Args:
|
||||
w (int): Image width.
|
||||
"""
|
||||
if self._bboxes.format == "xyxy":
|
||||
x1 = self.bboxes[:, 0].copy()
|
||||
x2 = self.bboxes[:, 2].copy()
|
||||
self.bboxes[:, 0] = w - x2
|
||||
self.bboxes[:, 2] = w - x1
|
||||
else:
|
||||
self.bboxes[:, 0] = w - self.bboxes[:, 0]
|
||||
self.segments[..., 0] = w - self.segments[..., 0]
|
||||
if self.keypoints is not None:
|
||||
self.keypoints[..., 0] = w - self.keypoints[..., 0]
|
||||
|
||||
def clip(self, w: int, h: int) -> None:
|
||||
"""Clip coordinates to stay within image boundaries.
|
||||
|
||||
Args:
|
||||
w (int): Image width.
|
||||
h (int): Image height.
|
||||
"""
|
||||
ori_format = self._bboxes.format
|
||||
self.convert_bbox(format="xyxy")
|
||||
self.bboxes[:, [0, 2]] = self.bboxes[:, [0, 2]].clip(0, w)
|
||||
self.bboxes[:, [1, 3]] = self.bboxes[:, [1, 3]].clip(0, h)
|
||||
if ori_format != "xyxy":
|
||||
self.convert_bbox(format=ori_format)
|
||||
self.segments[..., 0] = self.segments[..., 0].clip(0, w)
|
||||
self.segments[..., 1] = self.segments[..., 1].clip(0, h)
|
||||
if self.keypoints is not None:
|
||||
# Set out of bounds visibility to zero
|
||||
self.keypoints[..., 2][
|
||||
(self.keypoints[..., 0] < 0)
|
||||
| (self.keypoints[..., 0] > w)
|
||||
| (self.keypoints[..., 1] < 0)
|
||||
| (self.keypoints[..., 1] > h)
|
||||
] = 0.0
|
||||
self.keypoints[..., 0] = self.keypoints[..., 0].clip(0, w)
|
||||
self.keypoints[..., 1] = self.keypoints[..., 1].clip(0, h)
|
||||
|
||||
def remove_zero_area_boxes(self) -> np.ndarray:
|
||||
"""Remove zero-area boxes, i.e. after clipping some boxes may have zero width or height.
|
||||
|
||||
Returns:
|
||||
(np.ndarray): Boolean array indicating which boxes were kept.
|
||||
"""
|
||||
good = self.bbox_areas > 0
|
||||
if not all(good):
|
||||
self._bboxes = self._bboxes[good]
|
||||
if self.segments is not None and len(self.segments):
|
||||
self.segments = self.segments[good]
|
||||
if self.keypoints is not None:
|
||||
self.keypoints = self.keypoints[good]
|
||||
if self.difficulties is not None:
|
||||
self.difficulties = self.difficulties[good]
|
||||
if self.difficulty_levels is not None:
|
||||
self.difficulty_levels = self.difficulty_levels[good]
|
||||
return good
|
||||
|
||||
def update(
|
||||
self,
|
||||
bboxes: np.ndarray,
|
||||
segments: np.ndarray = None,
|
||||
keypoints: np.ndarray = None,
|
||||
difficulties: np.ndarray = None,
|
||||
difficulty_levels: np.ndarray = None,
|
||||
):
|
||||
"""Update instance variables.
|
||||
|
||||
Args:
|
||||
bboxes (np.ndarray): New bounding boxes.
|
||||
segments (np.ndarray, optional): New segments.
|
||||
keypoints (np.ndarray, optional): New keypoints.
|
||||
difficulties (np.ndarray, optional): New difficulty levels.
|
||||
"""
|
||||
self._bboxes = Bboxes(bboxes, format=self._bboxes.format)
|
||||
if segments is not None:
|
||||
self.segments = segments
|
||||
if keypoints is not None:
|
||||
self.keypoints = keypoints
|
||||
if difficulties is not None:
|
||||
self.difficulties = difficulties
|
||||
if difficulty_levels is not None:
|
||||
self.difficulty_levels = difficulty_levels
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of instances."""
|
||||
return len(self.bboxes)
|
||||
|
||||
@classmethod
|
||||
def concatenate(cls, instances_list: list[Instances], axis=0) -> Instances:
|
||||
"""Concatenate a list of Instances objects into a single Instances object.
|
||||
|
||||
Args:
|
||||
instances_list (list[Instances]): A list of Instances objects to concatenate.
|
||||
axis (int, optional): The axis along which the arrays will be concatenated.
|
||||
|
||||
Returns:
|
||||
(Instances): A new Instances object containing the concatenated bounding boxes, segments, and keypoints if
|
||||
present.
|
||||
|
||||
Notes:
|
||||
The `Instances` objects in the list should have the same properties, such as the format of the bounding
|
||||
boxes, whether keypoints are present, and if the coordinates are normalized.
|
||||
"""
|
||||
assert isinstance(instances_list, (list, tuple))
|
||||
if not instances_list:
|
||||
return cls(np.empty(0))
|
||||
assert all(isinstance(instance, Instances) for instance in instances_list)
|
||||
|
||||
if len(instances_list) == 1:
|
||||
return instances_list[0]
|
||||
|
||||
use_keypoint = instances_list[0].keypoints is not None
|
||||
use_difficulties = instances_list[0].difficulties is not None
|
||||
use_difficulty_levels = instances_list[0].difficulty_levels is not None
|
||||
bbox_format = instances_list[0]._bboxes.format
|
||||
normalized = instances_list[0].normalized
|
||||
|
||||
cat_boxes = np.concatenate([ins.bboxes for ins in instances_list], axis=axis)
|
||||
seg_len = [b.segments.shape[1] for b in instances_list]
|
||||
if len(frozenset(seg_len)) > 1: # resample segments if there's different length
|
||||
max_len = max(seg_len)
|
||||
cat_segments = np.concatenate(
|
||||
[
|
||||
resample_segments(list(b.segments), max_len)
|
||||
if len(b.segments)
|
||||
else np.zeros((0, max_len, 2), dtype=np.float32) # re-generating empty segments
|
||||
for b in instances_list
|
||||
],
|
||||
axis=axis,
|
||||
)
|
||||
else:
|
||||
cat_segments = np.concatenate([b.segments for b in instances_list], axis=axis)
|
||||
cat_keypoints = np.concatenate([b.keypoints for b in instances_list], axis=axis) if use_keypoint else None
|
||||
cat_difficulties = np.concatenate([b.difficulties for b in instances_list], axis=axis) if use_difficulties else None
|
||||
cat_difficulty_levels = (
|
||||
np.concatenate([b.difficulty_levels for b in instances_list], axis=axis) if use_difficulty_levels else None
|
||||
)
|
||||
return cls(
|
||||
cat_boxes,
|
||||
cat_segments,
|
||||
cat_keypoints,
|
||||
bbox_format,
|
||||
normalized,
|
||||
cat_difficulties,
|
||||
cat_difficulty_levels,
|
||||
)
|
||||
|
||||
@property
|
||||
def bboxes(self) -> np.ndarray:
|
||||
"""Return bounding boxes."""
|
||||
return self._bboxes.bboxes
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the Instances object."""
|
||||
# Map private to public names and include direct attributes
|
||||
attr_map = {"_bboxes": "bboxes"}
|
||||
parts = []
|
||||
for key, value in self.__dict__.items():
|
||||
name = attr_map.get(key, key)
|
||||
if name == "bboxes":
|
||||
value = self.bboxes # Use the property
|
||||
if value is not None:
|
||||
parts.append(f"{name}={value!r}")
|
||||
return "Instances({})".format("\n".join(parts))
|
||||
506
ultralytics/utils/logger.py
Executable file
506
ultralytics/utils/logger.py
Executable file
@@ -0,0 +1,506 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ultralytics.utils import LOGGER, MACOS, RANK
|
||||
from ultralytics.utils.checks import check_requirements
|
||||
|
||||
|
||||
class ConsoleLogger:
|
||||
"""Console output capture with batched streaming to file, API, or custom callback.
|
||||
|
||||
Captures stdout/stderr output and streams it with intelligent deduplication and configurable batching.
|
||||
|
||||
Attributes:
|
||||
destination (str | Path | None): Target destination for streaming (URL, Path, or None for callback-only).
|
||||
batch_size (int): Number of lines to batch before flushing (default: 1 for immediate).
|
||||
flush_interval (float): Seconds between automatic flushes (default: 5.0).
|
||||
on_flush (callable | None): Optional callback function called with batched content on flush.
|
||||
active (bool): Whether console capture is currently active.
|
||||
|
||||
Examples:
|
||||
File logging (immediate):
|
||||
>>> logger = ConsoleLogger("training.log")
|
||||
>>> logger.start_capture()
|
||||
>>> print("This will be logged")
|
||||
>>> logger.stop_capture()
|
||||
|
||||
API streaming with batching:
|
||||
>>> logger = ConsoleLogger("https://api.example.com/logs", batch_size=10)
|
||||
>>> logger.start_capture()
|
||||
|
||||
Custom callback with batching:
|
||||
>>> def my_handler(content, line_count, chunk_id):
|
||||
... print(f"Received {line_count} lines")
|
||||
>>> logger = ConsoleLogger(on_flush=my_handler, batch_size=5)
|
||||
>>> logger.start_capture()
|
||||
"""
|
||||
|
||||
def __init__(self, destination=None, batch_size=1, flush_interval=5.0, on_flush=None):
|
||||
"""Initialize console logger with optional batching.
|
||||
|
||||
Args:
|
||||
destination (str | Path | None): API endpoint URL (http/https), local file path, or None.
|
||||
batch_size (int): Lines to accumulate before flush (1 = immediate, higher = batched).
|
||||
flush_interval (float): Max seconds between flushes when batching.
|
||||
on_flush (callable | None): Callback(content: str, line_count: int, chunk_id: int) for custom handling.
|
||||
"""
|
||||
self.destination = destination
|
||||
self.is_api = isinstance(destination, str) and destination.startswith(("http://", "https://"))
|
||||
if destination is not None and not self.is_api:
|
||||
self.destination = Path(destination)
|
||||
|
||||
# Batching configuration
|
||||
self.batch_size = max(1, batch_size)
|
||||
self.flush_interval = flush_interval
|
||||
self.on_flush = on_flush
|
||||
|
||||
# Console capture state
|
||||
self.original_stdout = sys.stdout
|
||||
self.original_stderr = sys.stderr
|
||||
self.active = False
|
||||
self._log_handler = None # Track handler for cleanup
|
||||
|
||||
# Buffer for batching
|
||||
self.buffer = []
|
||||
self.buffer_lock = threading.Lock()
|
||||
self.flush_thread = None
|
||||
self.chunk_id = 0
|
||||
|
||||
# Deduplication state
|
||||
self.last_line = ""
|
||||
self.last_time = 0.0
|
||||
self.last_progress_line = "" # Track progress sequence key for deduplication
|
||||
self.last_was_progress = False # Track if last line was a progress bar
|
||||
|
||||
def start_capture(self):
|
||||
"""Start capturing console output and redirect stdout/stderr.
|
||||
|
||||
Notes:
|
||||
In DDP training, only activates on rank 0/-1 to prevent duplicate logging.
|
||||
"""
|
||||
if self.active or RANK not in {-1, 0}:
|
||||
return
|
||||
|
||||
self.active = True
|
||||
sys.stdout = self._ConsoleCapture(self.original_stdout, self._queue_log)
|
||||
sys.stderr = self._ConsoleCapture(self.original_stderr, self._queue_log)
|
||||
|
||||
# Hook Ultralytics logger
|
||||
try:
|
||||
self._log_handler = self._LogHandler(self._queue_log)
|
||||
logging.getLogger("ultralytics").addHandler(self._log_handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start background flush thread for batched mode
|
||||
if self.batch_size > 1:
|
||||
self.flush_thread = threading.Thread(target=self._flush_worker, daemon=True)
|
||||
self.flush_thread.start()
|
||||
|
||||
def stop_capture(self):
|
||||
"""Stop capturing console output and flush remaining buffer."""
|
||||
if not self.active:
|
||||
return
|
||||
|
||||
self.active = False
|
||||
sys.stdout = self.original_stdout
|
||||
sys.stderr = self.original_stderr
|
||||
|
||||
# Remove logging handler to prevent memory leak
|
||||
if self._log_handler:
|
||||
try:
|
||||
logging.getLogger("ultralytics").removeHandler(self._log_handler)
|
||||
except Exception:
|
||||
pass
|
||||
self._log_handler = None
|
||||
|
||||
# Final flush
|
||||
self._flush_buffer()
|
||||
|
||||
def _queue_log(self, text):
|
||||
"""Queue console text with deduplication and timestamp processing."""
|
||||
if not self.active:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Handle carriage returns and process lines
|
||||
if "\r" in text:
|
||||
text = text.split("\r")[-1]
|
||||
|
||||
lines = text.split("\n")
|
||||
if lines and lines[-1] == "":
|
||||
lines.pop()
|
||||
|
||||
for line in lines:
|
||||
line = line.rstrip()
|
||||
|
||||
# Skip lines with only thin progress bars (partial progress)
|
||||
if "─" in line: # Has thin lines but no thick lines
|
||||
continue
|
||||
|
||||
# Only show 100% completion lines for progress bars
|
||||
if " ━━" in line:
|
||||
is_complete = "100%" in line
|
||||
|
||||
# Skip ALL non-complete progress lines
|
||||
if not is_complete:
|
||||
continue
|
||||
|
||||
# Extract sequence key to deduplicate multiple 100% lines for same sequence
|
||||
parts = line.split()
|
||||
seq_key = ""
|
||||
if parts:
|
||||
# Check for epoch pattern (X/Y at start)
|
||||
if "/" in parts[0] and parts[0].replace("/", "").isdigit():
|
||||
seq_key = parts[0] # e.g., "1/3"
|
||||
elif parts[0] == "Class" and len(parts) > 1:
|
||||
seq_key = f"{parts[0]}_{parts[1]}" # e.g., "Class_train:" or "Class_val:"
|
||||
elif parts[0] in ("train:", "val:"):
|
||||
seq_key = parts[0] # Phase identifier
|
||||
|
||||
# Skip if we already showed 100% for this sequence
|
||||
if seq_key and self.last_progress_line == f"{seq_key}:done":
|
||||
continue
|
||||
|
||||
# Mark this sequence as done
|
||||
if seq_key:
|
||||
self.last_progress_line = f"{seq_key}:done"
|
||||
|
||||
self.last_was_progress = True
|
||||
else:
|
||||
# Skip empty line after progress bar
|
||||
if not line and self.last_was_progress:
|
||||
self.last_was_progress = False
|
||||
continue
|
||||
self.last_was_progress = False
|
||||
|
||||
# General deduplication
|
||||
if line == self.last_line and current_time - self.last_time < 0.1:
|
||||
continue
|
||||
|
||||
self.last_line = line
|
||||
self.last_time = current_time
|
||||
|
||||
# Add timestamp if needed
|
||||
if not line.startswith("[20"):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{timestamp}] {line}"
|
||||
|
||||
# Add to buffer and check if flush needed
|
||||
should_flush = False
|
||||
with self.buffer_lock:
|
||||
self.buffer.append(line)
|
||||
if len(self.buffer) >= self.batch_size:
|
||||
should_flush = True
|
||||
|
||||
# Flush outside lock to avoid deadlock
|
||||
if should_flush:
|
||||
self._flush_buffer()
|
||||
|
||||
def _flush_worker(self):
|
||||
"""Background worker that flushes buffer periodically."""
|
||||
while self.active:
|
||||
time.sleep(self.flush_interval)
|
||||
if self.active:
|
||||
self._flush_buffer()
|
||||
|
||||
def _flush_buffer(self):
|
||||
"""Flush buffered lines to destination and/or callback."""
|
||||
with self.buffer_lock:
|
||||
if not self.buffer:
|
||||
return
|
||||
lines = self.buffer.copy()
|
||||
self.buffer.clear()
|
||||
self.chunk_id += 1
|
||||
chunk_id = self.chunk_id # Capture under lock to avoid race
|
||||
|
||||
content = "\n".join(lines)
|
||||
line_count = len(lines)
|
||||
|
||||
# Call custom callback if provided
|
||||
if self.on_flush:
|
||||
try:
|
||||
self.on_flush(content, line_count, chunk_id)
|
||||
except Exception:
|
||||
pass # Silently ignore callback errors to avoid flooding stderr
|
||||
|
||||
# Write to destination (file or API)
|
||||
if self.destination is not None:
|
||||
self._write_destination(content)
|
||||
|
||||
def _write_destination(self, content):
|
||||
"""Write content to file or API destination."""
|
||||
try:
|
||||
if self.is_api:
|
||||
import requests
|
||||
|
||||
payload = {"timestamp": datetime.now().isoformat(), "message": content}
|
||||
requests.post(str(self.destination), json=payload, timeout=5)
|
||||
else:
|
||||
self.destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.destination.open("a", encoding="utf-8") as f:
|
||||
f.write(content + "\n")
|
||||
except Exception as e:
|
||||
print(f"Console logger write error: {e}", file=self.original_stderr)
|
||||
|
||||
class _ConsoleCapture:
|
||||
"""Lightweight stdout/stderr capture."""
|
||||
|
||||
__slots__ = ("callback", "original")
|
||||
|
||||
def __init__(self, original, callback):
|
||||
"""Initialize a stream wrapper that redirects writes to a callback while preserving the original."""
|
||||
self.original = original
|
||||
self.callback = callback
|
||||
|
||||
def write(self, text):
|
||||
"""Write text to the original stream and forward it to the capture callback."""
|
||||
self.original.write(text)
|
||||
self.callback(text)
|
||||
|
||||
def flush(self):
|
||||
"""Flush the wrapped stream to propagate buffered output promptly during console capture."""
|
||||
self.original.flush()
|
||||
|
||||
class _LogHandler(logging.Handler):
|
||||
"""Lightweight logging handler."""
|
||||
|
||||
__slots__ = ("callback",)
|
||||
|
||||
def __init__(self, callback):
|
||||
"""Initialize a lightweight logging.Handler that forwards log records to the provided callback."""
|
||||
super().__init__()
|
||||
self.callback = callback
|
||||
|
||||
def emit(self, record):
|
||||
"""Format and forward LogRecord messages to the capture callback for unified log streaming."""
|
||||
self.callback(self.format(record) + "\n")
|
||||
|
||||
|
||||
class SystemLogger:
|
||||
"""Log dynamic system metrics for training monitoring.
|
||||
|
||||
Captures real-time system metrics including CPU, RAM, disk I/O, network I/O, and NVIDIA GPU statistics for training
|
||||
performance monitoring and analysis.
|
||||
|
||||
Attributes:
|
||||
pynvml: NVIDIA pynvml module instance if successfully imported, None otherwise.
|
||||
nvidia_initialized (bool): Whether NVIDIA GPU monitoring is available and initialized.
|
||||
net_start: Initial network I/O counters for calculating cumulative usage.
|
||||
disk_start: Initial disk I/O counters for calculating cumulative usage.
|
||||
|
||||
Examples:
|
||||
Basic usage:
|
||||
>>> logger = SystemLogger()
|
||||
>>> metrics = logger.get_metrics()
|
||||
>>> print(f"CPU: {metrics['cpu']}%, RAM: {metrics['ram']}%")
|
||||
>>> if metrics["gpus"]:
|
||||
... gpu0 = metrics["gpus"]["0"]
|
||||
... print(f"GPU0: {gpu0['usage']}% usage, {gpu0['temp']}°C")
|
||||
|
||||
Training loop integration:
|
||||
>>> system_logger = SystemLogger()
|
||||
>>> for epoch in range(epochs):
|
||||
... # Training code here
|
||||
... metrics = system_logger.get_metrics()
|
||||
... # Log to database/file
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the system logger."""
|
||||
import psutil # scoped as slow import
|
||||
|
||||
self.pynvml = None
|
||||
self.nvidia_initialized = self._init_nvidia()
|
||||
self.net_start = psutil.net_io_counters()
|
||||
self.disk_start = psutil.disk_io_counters()
|
||||
|
||||
# For rate calculation
|
||||
self._prev_net = self.net_start
|
||||
self._prev_disk = self.disk_start
|
||||
self._prev_time = time.time()
|
||||
|
||||
def _init_nvidia(self):
|
||||
"""Initialize NVIDIA GPU monitoring with pynvml."""
|
||||
if MACOS:
|
||||
return False
|
||||
|
||||
try:
|
||||
check_requirements("nvidia-ml-py>=12.0.0")
|
||||
self.pynvml = __import__("pynvml")
|
||||
self.pynvml.nvmlInit()
|
||||
return True
|
||||
except Exception as e:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
LOGGER.warning(f"SystemLogger NVML init failed: {e}")
|
||||
return False
|
||||
|
||||
def get_metrics(self, rates=False):
|
||||
"""Get current system metrics including CPU, RAM, disk, network, and GPU usage.
|
||||
|
||||
Collects comprehensive system metrics including CPU usage, RAM usage, disk I/O statistics, network I/O
|
||||
statistics, and GPU metrics (if available).
|
||||
|
||||
Example output (rates=False, default):
|
||||
```python
|
||||
{
|
||||
"cpu": 45.2,
|
||||
"ram": 78.9,
|
||||
"disk": {"read_mb": 156.7, "write_mb": 89.3, "used_gb": 256.8},
|
||||
"network": {"recv_mb": 157.2, "sent_mb": 89.1},
|
||||
"gpus": {
|
||||
"0": {"usage": 95.6, "memory": 85.4, "temp": 72, "power": 285},
|
||||
"1": {"usage": 94.1, "memory": 82.7, "temp": 70, "power": 278},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Example output (rates=True):
|
||||
```python
|
||||
{
|
||||
"cpu": 45.2,
|
||||
"ram": 78.9,
|
||||
"disk": {"read_mbs": 12.5, "write_mbs": 8.3, "used_gb": 256.8},
|
||||
"network": {"recv_mbs": 5.2, "sent_mbs": 1.1},
|
||||
"gpus": {
|
||||
"0": {"usage": 95.6, "memory": 85.4, "temp": 72, "power": 285},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
rates (bool): If True, return disk/network as MB/s rates instead of cumulative MB.
|
||||
|
||||
Returns:
|
||||
(dict): Metrics dictionary with cpu, ram, disk, network, and gpus keys.
|
||||
|
||||
Examples:
|
||||
>>> logger = SystemLogger()
|
||||
>>> logger.get_metrics()["cpu"] # CPU percentage
|
||||
>>> logger.get_metrics(rates=True)["network"]["recv_mbs"] # MB/s download rate
|
||||
"""
|
||||
import psutil # scoped as slow import
|
||||
|
||||
net = psutil.net_io_counters()
|
||||
disk = psutil.disk_io_counters()
|
||||
memory = psutil.virtual_memory()
|
||||
disk_usage = shutil.disk_usage("/")
|
||||
now = time.time()
|
||||
|
||||
metrics = {
|
||||
"cpu": round(psutil.cpu_percent(), 3),
|
||||
"ram": round(memory.percent, 3),
|
||||
"gpus": {},
|
||||
}
|
||||
|
||||
# Calculate elapsed time since last call
|
||||
elapsed = max(0.1, now - self._prev_time) # Avoid division by zero
|
||||
|
||||
if rates:
|
||||
# Calculate MB/s rates from delta since last call
|
||||
metrics["disk"] = {
|
||||
"read_mbs": round(max(0, (disk.read_bytes - self._prev_disk.read_bytes) / (1 << 20) / elapsed), 3),
|
||||
"write_mbs": round(max(0, (disk.write_bytes - self._prev_disk.write_bytes) / (1 << 20) / elapsed), 3),
|
||||
"used_gb": round(disk_usage.used / (1 << 30), 3),
|
||||
}
|
||||
metrics["network"] = {
|
||||
"recv_mbs": round(max(0, (net.bytes_recv - self._prev_net.bytes_recv) / (1 << 20) / elapsed), 3),
|
||||
"sent_mbs": round(max(0, (net.bytes_sent - self._prev_net.bytes_sent) / (1 << 20) / elapsed), 3),
|
||||
}
|
||||
else:
|
||||
# Cumulative MB since initialization (original behavior)
|
||||
metrics["disk"] = {
|
||||
"read_mb": round((disk.read_bytes - self.disk_start.read_bytes) / (1 << 20), 3),
|
||||
"write_mb": round((disk.write_bytes - self.disk_start.write_bytes) / (1 << 20), 3),
|
||||
"used_gb": round(disk_usage.used / (1 << 30), 3),
|
||||
}
|
||||
metrics["network"] = {
|
||||
"recv_mb": round((net.bytes_recv - self.net_start.bytes_recv) / (1 << 20), 3),
|
||||
"sent_mb": round((net.bytes_sent - self.net_start.bytes_sent) / (1 << 20), 3),
|
||||
}
|
||||
|
||||
# Always update previous values for accurate rate calculation on next call
|
||||
self._prev_net = net
|
||||
self._prev_disk = disk
|
||||
self._prev_time = now
|
||||
|
||||
# Add GPU metrics (NVIDIA only)
|
||||
if self.nvidia_initialized:
|
||||
metrics["gpus"].update(self._get_nvidia_metrics())
|
||||
|
||||
return metrics
|
||||
|
||||
def _get_nvidia_metrics(self):
|
||||
"""Get NVIDIA GPU metrics including utilization, memory, temperature, and power."""
|
||||
gpus = {}
|
||||
if not self.nvidia_initialized or not self.pynvml:
|
||||
return gpus
|
||||
try:
|
||||
device_count = self.pynvml.nvmlDeviceGetCount()
|
||||
for i in range(device_count):
|
||||
handle = self.pynvml.nvmlDeviceGetHandleByIndex(i)
|
||||
util = self.pynvml.nvmlDeviceGetUtilizationRates(handle)
|
||||
memory = self.pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||
temp = self.pynvml.nvmlDeviceGetTemperature(handle, self.pynvml.NVML_TEMPERATURE_GPU)
|
||||
power = self.pynvml.nvmlDeviceGetPowerUsage(handle) // 1000
|
||||
|
||||
gpus[str(i)] = {
|
||||
"usage": round(util.gpu, 3),
|
||||
"memory": round((memory.used / memory.total) * 100, 3),
|
||||
"temp": temp,
|
||||
"power": power,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return gpus
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("SystemLogger Real-time Metrics Monitor")
|
||||
print("Press Ctrl+C to stop\n")
|
||||
|
||||
logger = SystemLogger()
|
||||
|
||||
try:
|
||||
while True:
|
||||
metrics = logger.get_metrics()
|
||||
|
||||
# Clear screen (works on most terminals)
|
||||
print("\033[H\033[J", end="")
|
||||
|
||||
# Display system metrics
|
||||
print(f"CPU: {metrics['cpu']:5.1f}%")
|
||||
print(f"RAM: {metrics['ram']:5.1f}%")
|
||||
print(f"Disk Read: {metrics['disk']['read_mb']:8.1f} MB")
|
||||
print(f"Disk Write: {metrics['disk']['write_mb']:7.1f} MB")
|
||||
print(f"Disk Used: {metrics['disk']['used_gb']:8.1f} GB")
|
||||
print(f"Net Recv: {metrics['network']['recv_mb']:9.1f} MB")
|
||||
print(f"Net Sent: {metrics['network']['sent_mb']:9.1f} MB")
|
||||
|
||||
# Display GPU metrics if available
|
||||
if metrics["gpus"]:
|
||||
print("\nGPU Metrics:")
|
||||
for gpu_id, gpu_data in metrics["gpus"].items():
|
||||
print(
|
||||
f" GPU {gpu_id}: {gpu_data['usage']:3}% | "
|
||||
f"Mem: {gpu_data['memory']:5.1f}% | "
|
||||
f"Temp: {gpu_data['temp']:2}°C | "
|
||||
f"Power: {gpu_data['power']:3}W"
|
||||
)
|
||||
else:
|
||||
print("\nGPU: No NVIDIA GPUs detected")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nStopped monitoring.")
|
||||
2014
ultralytics/utils/loss.py
Executable file
2014
ultralytics/utils/loss.py
Executable file
File diff suppressed because it is too large
Load Diff
1566
ultralytics/utils/metrics.py
Executable file
1566
ultralytics/utils/metrics.py
Executable file
File diff suppressed because it is too large
Load Diff
267
ultralytics/utils/metrics_3d.py
Executable file
267
ultralytics/utils/metrics_3d.py
Executable file
@@ -0,0 +1,267 @@
|
||||
# Ultralytics AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
"""3D detection metrics for monocular 3D object detection.
|
||||
|
||||
Ported from yolov5-3d/utils/metrics.py. Provides depth error, orientation error,
|
||||
3D center error, UV error, and grouped aggregation for matched prediction-GT pairs.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def compute_depth_error(pred_depth, gt_depth, eps=1e-7):
|
||||
"""Compute depth error metrics between predicted and ground truth depth.
|
||||
|
||||
Args:
|
||||
pred_depth: Predicted depth values (N,).
|
||||
gt_depth: Ground truth depth values (N,).
|
||||
eps: Small value to avoid division by zero.
|
||||
|
||||
Returns:
|
||||
Dict with abs_error, rel_error, rmse.
|
||||
"""
|
||||
pred_depth = np.asarray(pred_depth, dtype=np.float64)
|
||||
gt_depth = np.asarray(gt_depth, dtype=np.float64)
|
||||
|
||||
valid = np.isfinite(pred_depth) & np.isfinite(gt_depth) & (gt_depth > 0)
|
||||
if not np.any(valid):
|
||||
return {"abs_error": 0.0, "rel_error": 0.0, "rmse": 0.0}
|
||||
|
||||
p, g = pred_depth[valid], gt_depth[valid]
|
||||
abs_err = np.abs(p - g)
|
||||
return {
|
||||
"abs_error": float(np.mean(abs_err)),
|
||||
"rel_error": float(np.mean(abs_err / (g + eps))),
|
||||
"rmse": float(np.sqrt(np.mean((p - g) ** 2))),
|
||||
}
|
||||
|
||||
|
||||
def compute_orientation_error(pred_yaw, gt_yaw):
|
||||
"""Compute mean absolute orientation error in degrees with wrap-around handling.
|
||||
|
||||
Args:
|
||||
pred_yaw: Predicted yaw angles in radians (N,).
|
||||
gt_yaw: Ground truth yaw angles in radians (N,).
|
||||
|
||||
Returns:
|
||||
Mean absolute orientation error in degrees.
|
||||
"""
|
||||
pred_yaw = np.asarray(pred_yaw, dtype=np.float64)
|
||||
gt_yaw = np.asarray(gt_yaw, dtype=np.float64)
|
||||
|
||||
valid = np.isfinite(pred_yaw) & np.isfinite(gt_yaw)
|
||||
if not np.any(valid):
|
||||
return 0.0
|
||||
|
||||
diff = np.abs(pred_yaw[valid] - gt_yaw[valid])
|
||||
diff = np.minimum(diff, 2 * math.pi - diff)
|
||||
return float(np.mean(np.degrees(diff)))
|
||||
|
||||
|
||||
def count_valid_orientation_pairs(pred_yaw, gt_yaw):
|
||||
"""Count valid orientation pairs with finite prediction and ground-truth yaw."""
|
||||
pred_yaw = np.asarray(pred_yaw, dtype=np.float64)
|
||||
gt_yaw = np.asarray(gt_yaw, dtype=np.float64)
|
||||
return int(np.sum(np.isfinite(pred_yaw) & np.isfinite(gt_yaw)))
|
||||
|
||||
|
||||
def compute_visible_orientation_metrics(pred_direct_yaw, pred_edge_yaw, gt_yaw):
|
||||
"""Compute visible-face direct and edge orientation errors against GT yaw."""
|
||||
gt_yaw = np.asarray(gt_yaw, dtype=np.float64)
|
||||
pred_direct_yaw = np.asarray(pred_direct_yaw, dtype=np.float64)
|
||||
pred_edge_yaw = np.asarray(pred_edge_yaw, dtype=np.float64)
|
||||
|
||||
return {
|
||||
"direct_orient_visible": compute_orientation_error(pred_direct_yaw, gt_yaw),
|
||||
"edge_orient_visible": compute_orientation_error(pred_edge_yaw, gt_yaw),
|
||||
"_direct_orient_visible_matched": count_valid_orientation_pairs(pred_direct_yaw, gt_yaw),
|
||||
"_edge_orient_visible_matched": count_valid_orientation_pairs(pred_edge_yaw, gt_yaw),
|
||||
}
|
||||
|
||||
|
||||
def compute_3d_center_error(pred_center, gt_center):
|
||||
"""Compute mean Euclidean distance between predicted and GT 3D centers.
|
||||
|
||||
Args:
|
||||
pred_center: Predicted 3D centers (N, 3).
|
||||
gt_center: Ground truth 3D centers (N, 3).
|
||||
|
||||
Returns:
|
||||
Mean Euclidean distance in meters.
|
||||
"""
|
||||
pred_center = np.asarray(pred_center, dtype=np.float64)
|
||||
gt_center = np.asarray(gt_center, dtype=np.float64)
|
||||
|
||||
valid = np.all(np.isfinite(pred_center), axis=1) & np.all(np.isfinite(gt_center), axis=1)
|
||||
if not np.any(valid):
|
||||
return 0.0
|
||||
|
||||
return float(np.mean(np.linalg.norm(pred_center[valid] - gt_center[valid], axis=1)))
|
||||
|
||||
|
||||
def compute_size_error(pred_dims, gt_dims):
|
||||
"""Compute mean absolute size error for L, H, W dimensions.
|
||||
|
||||
Args:
|
||||
pred_dims: Predicted dimensions (N, 3) - [l, h, w].
|
||||
gt_dims: Ground truth dimensions (N, 3) - [l, h, w].
|
||||
|
||||
Returns:
|
||||
Mean absolute size error in meters.
|
||||
"""
|
||||
pred_dims = np.asarray(pred_dims, dtype=np.float64)
|
||||
gt_dims = np.asarray(gt_dims, dtype=np.float64)
|
||||
|
||||
valid = np.all(np.isfinite(pred_dims), axis=1) & np.all(np.isfinite(gt_dims), axis=1)
|
||||
if not np.any(valid):
|
||||
return 0.0
|
||||
|
||||
return float(np.mean(np.abs(pred_dims[valid] - gt_dims[valid])))
|
||||
|
||||
|
||||
def compute_uv_error(pred_uv, gt_uv):
|
||||
"""Compute mean per-coordinate L1 pixel error between predicted and GT UV coordinates.
|
||||
|
||||
This matches the training-time UV logging, which averages absolute U and V errors
|
||||
instead of using Euclidean point distance.
|
||||
"""
|
||||
pred_uv = np.asarray(pred_uv, dtype=np.float64)
|
||||
gt_uv = np.asarray(gt_uv, dtype=np.float64)
|
||||
|
||||
valid = np.all(np.isfinite(pred_uv), axis=1) & np.all(np.isfinite(gt_uv), axis=1)
|
||||
if not np.any(valid):
|
||||
return 0.0
|
||||
|
||||
diff = np.abs(pred_uv[valid] - gt_uv[valid])
|
||||
return float(np.mean(diff))
|
||||
|
||||
|
||||
def empty_3d_metrics(include_orient=True, include_size=True, include_uv=True, include_visible_orient=False):
|
||||
"""Return default 3D metrics used for logging when no matches are available."""
|
||||
metrics = {
|
||||
"depth_abs": 0.0,
|
||||
"depth_rel": 0.0,
|
||||
"depth_rmse": 0.0,
|
||||
"center": 0.0,
|
||||
"matched": 0,
|
||||
}
|
||||
if include_uv:
|
||||
metrics["uv"] = 0.0
|
||||
if include_orient:
|
||||
metrics["orient"] = 0.0
|
||||
if include_size:
|
||||
metrics["size"] = 0.0
|
||||
if include_visible_orient:
|
||||
metrics["direct_orient_visible"] = 0.0
|
||||
metrics["edge_orient_visible"] = 0.0
|
||||
return metrics
|
||||
|
||||
|
||||
def aggregate_3d_metric_groups(stats_by_group):
|
||||
"""Aggregate grouped 3D metrics with matched-count weighting."""
|
||||
aggregated = {}
|
||||
for group, entries in stats_by_group.items():
|
||||
if not entries:
|
||||
aggregated[group] = empty_3d_metrics(
|
||||
include_orient=group == "whole",
|
||||
include_size=group == "whole",
|
||||
include_visible_orient=group == "face",
|
||||
)
|
||||
continue
|
||||
|
||||
template = {k: 0.0 for k in entries[0] if k != "matched" and not k.startswith("_")}
|
||||
template["matched"] = 0
|
||||
total_matched = sum(entry["matched"] for entry in entries)
|
||||
total_pos_matched = sum(entry.get("_pos_matched", entry["matched"]) for entry in entries)
|
||||
if total_matched <= 0 and total_pos_matched <= 0:
|
||||
aggregated[group] = template
|
||||
continue
|
||||
|
||||
for key in template:
|
||||
if key == "matched":
|
||||
continue
|
||||
if key in {"depth_abs", "depth_rel", "depth_rmse", "center", "uv"}:
|
||||
weight_key = "_pos_matched"
|
||||
elif key == "direct_orient_visible":
|
||||
weight_key = "_direct_orient_visible_matched"
|
||||
elif key == "edge_orient_visible":
|
||||
weight_key = "_edge_orient_visible_matched"
|
||||
else:
|
||||
weight_key = "matched"
|
||||
total_weight = sum(entry.get(weight_key, entry.get("matched", 0)) for entry in entries)
|
||||
if total_weight <= 0:
|
||||
template[key] = float("nan") if key in {"direct_orient_visible", "edge_orient_visible"} else 0.0
|
||||
continue
|
||||
weighted = sum(
|
||||
entry[key] * entry.get(weight_key, entry.get("matched", 0))
|
||||
for entry in entries
|
||||
if entry.get(weight_key, entry.get("matched", 0)) > 0
|
||||
)
|
||||
template[key] = round(weighted / total_weight, 5)
|
||||
template["matched"] = total_matched
|
||||
aggregated[group] = template
|
||||
return aggregated
|
||||
|
||||
|
||||
def compute_3d_metrics_for_matched(
|
||||
pred_3d_attrs,
|
||||
gt_3d_attrs,
|
||||
include_orient=True,
|
||||
include_size=True,
|
||||
include_uv=False,
|
||||
include_visible_orient=False,
|
||||
):
|
||||
"""Compute 3D metrics for pre-matched prediction-GT pairs.
|
||||
|
||||
Args:
|
||||
pred_3d_attrs: Dict with keys:
|
||||
- center: (N, 3) predicted 3D centers [x, y, z]
|
||||
- depth: (N,) predicted z3d
|
||||
- yaw: (N,) predicted rotation_y in radians
|
||||
- edge_yaw: (N,) predicted visible-face yaw in radians (optional)
|
||||
- dims: (N, 3) predicted [l, h, w]
|
||||
- uv: (N, 2) predicted [u, v] in pixels (optional)
|
||||
gt_3d_attrs: Dict with same keys for ground truth.
|
||||
include_orient: Whether to compute orientation error.
|
||||
include_size: Whether to compute size error.
|
||||
include_uv: Whether to compute UV pixel error.
|
||||
include_visible_orient: Whether to compute visible-face direct and edge orientation errors.
|
||||
|
||||
Returns:
|
||||
Dict with aggregated metrics and matched count.
|
||||
"""
|
||||
n = len(pred_3d_attrs.get("depth", []))
|
||||
if n == 0:
|
||||
return empty_3d_metrics(
|
||||
include_orient=include_orient,
|
||||
include_size=include_size,
|
||||
include_uv=include_uv,
|
||||
include_visible_orient=include_visible_orient,
|
||||
)
|
||||
|
||||
depth_m = compute_depth_error(pred_3d_attrs["depth"], gt_3d_attrs["depth"])
|
||||
center_m = compute_3d_center_error(pred_3d_attrs["center"], gt_3d_attrs["center"])
|
||||
metrics = {
|
||||
"depth_abs": depth_m["abs_error"],
|
||||
"depth_rel": depth_m["rel_error"],
|
||||
"depth_rmse": depth_m["rmse"],
|
||||
"center": center_m,
|
||||
"matched": n,
|
||||
}
|
||||
if include_uv:
|
||||
metrics["uv"] = compute_uv_error(pred_3d_attrs["uv"], gt_3d_attrs["uv"])
|
||||
if include_orient:
|
||||
metrics["orient"] = compute_orientation_error(pred_3d_attrs["yaw"], gt_3d_attrs["yaw"])
|
||||
if include_size:
|
||||
metrics["size"] = compute_size_error(pred_3d_attrs["dims"], gt_3d_attrs["dims"])
|
||||
if include_visible_orient:
|
||||
metrics.update(
|
||||
compute_visible_orientation_metrics(
|
||||
pred_3d_attrs["yaw"],
|
||||
pred_3d_attrs.get("edge_yaw", np.full(n, np.nan, dtype=np.float64)),
|
||||
gt_3d_attrs["yaw"],
|
||||
)
|
||||
)
|
||||
return metrics
|
||||
338
ultralytics/utils/nms.py
Executable file
338
ultralytics/utils/nms.py
Executable file
@@ -0,0 +1,338 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import torch
|
||||
|
||||
from ultralytics.utils import LOGGER
|
||||
from ultralytics.utils.metrics import batch_probiou, box_iou
|
||||
from ultralytics.utils.ops import xywh2xyxy
|
||||
|
||||
|
||||
def non_max_suppression(
|
||||
prediction,
|
||||
conf_thres: float = 0.25,
|
||||
iou_thres: float = 0.45,
|
||||
classes=None,
|
||||
agnostic: bool = False,
|
||||
multi_label: bool = False,
|
||||
labels=(),
|
||||
max_det: int = 300,
|
||||
nc: int = 0, # number of classes (optional)
|
||||
max_time_img: float = 0.05,
|
||||
max_nms: int = 30000,
|
||||
max_wh: int = 7680,
|
||||
rotated: bool = False,
|
||||
end2end: bool = False,
|
||||
return_idxs: bool = False,
|
||||
):
|
||||
"""Perform non-maximum suppression (NMS) on prediction results.
|
||||
|
||||
Applies NMS to filter overlapping bounding boxes based on confidence and IoU thresholds. Supports multiple detection
|
||||
formats including standard boxes, rotated boxes, and masks.
|
||||
|
||||
Args:
|
||||
prediction (torch.Tensor): Predictions with shape (batch_size, num_classes + 4 + num_masks, num_boxes)
|
||||
containing boxes, classes, and optional masks.
|
||||
conf_thres (float): Confidence threshold for filtering detections. Valid values are between 0.0 and 1.0.
|
||||
iou_thres (float): IoU threshold for NMS filtering. Valid values are between 0.0 and 1.0.
|
||||
classes (list[int], optional): List of class indices to consider. If None, all classes are considered.
|
||||
agnostic (bool): Whether to perform class-agnostic NMS.
|
||||
multi_label (bool): Whether each box can have multiple labels.
|
||||
labels (list[torch.Tensor]): A priori labels for each image.
|
||||
max_det (int): Maximum number of detections to keep per image.
|
||||
nc (int): Number of classes. Indices after this are considered masks.
|
||||
max_time_img (float): Maximum time in seconds for processing one image.
|
||||
max_nms (int): Maximum number of boxes for NMS.
|
||||
max_wh (int): Maximum box width and height in pixels.
|
||||
rotated (bool): Whether to handle Oriented Bounding Boxes (OBB).
|
||||
end2end (bool): Whether the model is end-to-end and doesn't require NMS.
|
||||
return_idxs (bool): Whether to return the indices of kept detections.
|
||||
|
||||
Returns:
|
||||
(list[torch.Tensor] | tuple[list[torch.Tensor], list[torch.Tensor]]): List of detections per image with shape
|
||||
(num_boxes, 6 + num_masks) containing (x1, y1, x2, y2, confidence, class, mask1, mask2, ...). If
|
||||
return_idxs=True, returns a tuple of (output, keepi) where keepi contains indices of kept detections.
|
||||
"""
|
||||
# Checks
|
||||
assert 0 <= conf_thres <= 1, f"Invalid Confidence threshold {conf_thres}, valid values are between 0.0 and 1.0"
|
||||
assert 0 <= iou_thres <= 1, f"Invalid IoU {iou_thres}, valid values are between 0.0 and 1.0"
|
||||
if isinstance(prediction, (list, tuple)): # YOLOv8 model in validation model, output = (inference_out, loss_out)
|
||||
prediction = prediction[0] # select only inference output
|
||||
if classes is not None:
|
||||
classes = torch.tensor(classes, device=prediction.device)
|
||||
|
||||
if prediction.shape[-1] == 6 or end2end: # end-to-end model (BNC, i.e. 1,300,6)
|
||||
output = [pred[pred[:, 4] > conf_thres][:max_det] for pred in prediction]
|
||||
if classes is not None:
|
||||
output = [pred[(pred[:, 5:6] == classes).any(1)] for pred in output]
|
||||
return output
|
||||
|
||||
bs = prediction.shape[0] # batch size (BCN, i.e. 1,84,6300)
|
||||
nc = nc or (prediction.shape[1] - 4) # number of classes
|
||||
extra = prediction.shape[1] - nc - 4 # number of extra info
|
||||
mi = 4 + nc # mask start index
|
||||
xc = prediction[:, 4:mi].amax(1) > conf_thres # candidates
|
||||
xinds = torch.arange(prediction.shape[-1], device=prediction.device).expand(bs, -1)[..., None] # to track idxs
|
||||
|
||||
# Settings
|
||||
# min_wh = 2 # (pixels) minimum box width and height
|
||||
time_limit = 2.0 + max_time_img * bs # seconds to quit after
|
||||
multi_label &= nc > 1 # multiple labels per box (adds 0.5ms/img)
|
||||
|
||||
prediction = prediction.transpose(-1, -2) # shape(1,84,6300) to shape(1,6300,84)
|
||||
if not rotated:
|
||||
prediction[..., :4] = xywh2xyxy(prediction[..., :4]) # xywh to xyxy
|
||||
|
||||
t = time.time()
|
||||
output = [torch.zeros((0, 6 + extra), device=prediction.device)] * bs
|
||||
keepi = [torch.zeros((0, 1), device=prediction.device)] * bs # to store the kept idxs
|
||||
for xi, (x, xk) in enumerate(zip(prediction, xinds)): # image index, (preds, preds indices)
|
||||
# Apply constraints
|
||||
# x[((x[:, 2:4] < min_wh) | (x[:, 2:4] > max_wh)).any(1), 4] = 0 # width-height
|
||||
filt = xc[xi] # confidence
|
||||
x = x[filt]
|
||||
if return_idxs:
|
||||
xk = xk[filt]
|
||||
|
||||
# Cat apriori labels if autolabelling
|
||||
if labels and len(labels[xi]) and not rotated:
|
||||
lb = labels[xi]
|
||||
v = torch.zeros((len(lb), nc + extra + 4), device=x.device)
|
||||
v[:, :4] = xywh2xyxy(lb[:, 1:5]) # box
|
||||
v[range(len(lb)), lb[:, 0].long() + 4] = 1.0 # cls
|
||||
x = torch.cat((x, v), 0)
|
||||
|
||||
# If none remain process next image
|
||||
if not x.shape[0]:
|
||||
continue
|
||||
|
||||
# Detections matrix nx6 (xyxy, conf, cls)
|
||||
box, cls, mask = x.split((4, nc, extra), 1)
|
||||
|
||||
if multi_label:
|
||||
i, j = torch.where(cls > conf_thres)
|
||||
x = torch.cat((box[i], x[i, 4 + j, None], j[:, None].float(), mask[i]), 1)
|
||||
if return_idxs:
|
||||
xk = xk[i]
|
||||
else: # best class only
|
||||
conf, j = cls.max(1, keepdim=True)
|
||||
filt = conf.view(-1) > conf_thres
|
||||
x = torch.cat((box, conf, j.float(), mask), 1)[filt]
|
||||
if return_idxs:
|
||||
xk = xk[filt]
|
||||
|
||||
# Filter by class
|
||||
if classes is not None:
|
||||
filt = (x[:, 5:6] == classes).any(1)
|
||||
x = x[filt]
|
||||
if return_idxs:
|
||||
xk = xk[filt]
|
||||
|
||||
# Check shape
|
||||
n = x.shape[0] # number of boxes
|
||||
if not n: # no boxes
|
||||
continue
|
||||
if n > max_nms: # excess boxes
|
||||
filt = x[:, 4].argsort(descending=True)[:max_nms] # sort by confidence and remove excess boxes
|
||||
x = x[filt]
|
||||
if return_idxs:
|
||||
xk = xk[filt]
|
||||
|
||||
c = x[:, 5:6] * (0 if agnostic else max_wh) # classes
|
||||
scores = x[:, 4] # scores
|
||||
if rotated:
|
||||
boxes = torch.cat((x[:, :2] + c, x[:, 2:4], x[:, -1:]), dim=-1) # xywhr
|
||||
i = TorchNMS.fast_nms(boxes, scores, iou_thres, iou_func=batch_probiou)
|
||||
else:
|
||||
boxes = x[:, :4] + c # boxes (offset by class)
|
||||
# Speed strategy: torchvision for val or already loaded (faster), TorchNMS for predict (lower latency)
|
||||
if "torchvision" in sys.modules:
|
||||
import torchvision # scope as slow import
|
||||
|
||||
i = torchvision.ops.nms(boxes, scores, iou_thres)
|
||||
else:
|
||||
i = TorchNMS.nms(boxes, scores, iou_thres)
|
||||
i = i[:max_det] # limit detections
|
||||
|
||||
output[xi] = x[i]
|
||||
if return_idxs:
|
||||
keepi[xi] = xk[i].view(-1)
|
||||
if (time.time() - t) > time_limit:
|
||||
LOGGER.warning(f"NMS time limit {time_limit:.3f}s exceeded")
|
||||
break # time limit exceeded
|
||||
|
||||
return (output, keepi) if return_idxs else output
|
||||
|
||||
|
||||
class TorchNMS:
|
||||
"""Ultralytics custom NMS implementation optimized for YOLO.
|
||||
|
||||
This class provides static methods for performing non-maximum suppression (NMS) operations on bounding boxes,
|
||||
including standard NMS, fast NMS, and batched NMS for multi-class scenarios.
|
||||
|
||||
Methods:
|
||||
fast_nms: Fast-NMS using upper triangular matrix operations.
|
||||
nms: Optimized NMS with early termination that matches torchvision behavior exactly.
|
||||
batched_nms: Batched NMS for class-aware suppression.
|
||||
|
||||
Examples:
|
||||
Perform standard NMS on boxes and scores
|
||||
>>> boxes = torch.tensor([[0, 0, 10, 10], [5, 5, 15, 15]])
|
||||
>>> scores = torch.tensor([0.9, 0.8])
|
||||
>>> keep = TorchNMS.nms(boxes, scores, 0.5)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def fast_nms(
|
||||
boxes: torch.Tensor,
|
||||
scores: torch.Tensor,
|
||||
iou_threshold: float,
|
||||
use_triu: bool = True,
|
||||
iou_func=box_iou,
|
||||
exit_early: bool = True,
|
||||
) -> torch.Tensor:
|
||||
"""Fast-NMS implementation from https://arxiv.org/pdf/1904.02689 using upper triangular matrix operations.
|
||||
|
||||
Args:
|
||||
boxes (torch.Tensor): Bounding boxes with shape (N, 4) in xyxy format.
|
||||
scores (torch.Tensor): Confidence scores with shape (N,).
|
||||
iou_threshold (float): IoU threshold for suppression.
|
||||
use_triu (bool): Whether to use torch.triu operator for upper triangular matrix operations.
|
||||
iou_func (callable): Function to compute IoU between boxes.
|
||||
exit_early (bool): Whether to exit early if there are no boxes.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Indices of boxes to keep after NMS.
|
||||
|
||||
Examples:
|
||||
Apply NMS to a set of boxes
|
||||
>>> boxes = torch.tensor([[0, 0, 10, 10], [5, 5, 15, 15]])
|
||||
>>> scores = torch.tensor([0.9, 0.8])
|
||||
>>> keep = TorchNMS.fast_nms(boxes, scores, 0.5)
|
||||
"""
|
||||
if boxes.numel() == 0 and exit_early:
|
||||
return torch.empty((0,), dtype=torch.int64, device=boxes.device)
|
||||
|
||||
sorted_idx = torch.argsort(scores, descending=True)
|
||||
boxes = boxes[sorted_idx]
|
||||
ious = iou_func(boxes, boxes)
|
||||
if use_triu:
|
||||
ious = ious.triu_(diagonal=1)
|
||||
# NOTE: handle the case when len(boxes) hence exportable by eliminating if-else condition
|
||||
pick = torch.nonzero((ious >= iou_threshold).sum(0) <= 0).squeeze_(-1)
|
||||
else:
|
||||
n = boxes.shape[0]
|
||||
row_idx = torch.arange(n, device=boxes.device).view(-1, 1).expand(-1, n)
|
||||
col_idx = torch.arange(n, device=boxes.device).view(1, -1).expand(n, -1)
|
||||
upper_mask = row_idx < col_idx
|
||||
ious = ious * upper_mask
|
||||
# Zeroing these scores ensures the additional indices would not affect the final results
|
||||
scores_ = scores[sorted_idx]
|
||||
scores_[~((ious >= iou_threshold).sum(0) <= 0)] = 0
|
||||
scores[sorted_idx] = scores_ # update original tensor for NMSModel
|
||||
# NOTE: return indices with fixed length to avoid TFLite reshape error
|
||||
pick = torch.topk(scores_, scores_.shape[0]).indices
|
||||
return sorted_idx[pick]
|
||||
|
||||
@staticmethod
|
||||
def nms(boxes: torch.Tensor, scores: torch.Tensor, iou_threshold: float) -> torch.Tensor:
|
||||
"""Optimized NMS with early termination that matches torchvision behavior exactly.
|
||||
|
||||
Args:
|
||||
boxes (torch.Tensor): Bounding boxes with shape (N, 4) in xyxy format.
|
||||
scores (torch.Tensor): Confidence scores with shape (N,).
|
||||
iou_threshold (float): IoU threshold for suppression.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Indices of boxes to keep after NMS.
|
||||
|
||||
Examples:
|
||||
Apply NMS to a set of boxes
|
||||
>>> boxes = torch.tensor([[0, 0, 10, 10], [5, 5, 15, 15]])
|
||||
>>> scores = torch.tensor([0.9, 0.8])
|
||||
>>> keep = TorchNMS.nms(boxes, scores, 0.5)
|
||||
"""
|
||||
if boxes.numel() == 0:
|
||||
return torch.empty((0,), dtype=torch.int64, device=boxes.device)
|
||||
|
||||
# Pre-allocate and extract coordinates once
|
||||
x1, y1, x2, y2 = boxes.unbind(1)
|
||||
areas = (x2 - x1) * (y2 - y1)
|
||||
|
||||
# Sort by scores descending
|
||||
order = scores.argsort(0, descending=True)
|
||||
|
||||
# Pre-allocate keep list with maximum possible size
|
||||
keep = torch.zeros(order.numel(), dtype=torch.int64, device=boxes.device)
|
||||
keep_idx = 0
|
||||
while order.numel() > 0:
|
||||
i = order[0]
|
||||
keep[keep_idx] = i
|
||||
keep_idx += 1
|
||||
|
||||
if order.numel() == 1:
|
||||
break
|
||||
# Vectorized IoU calculation for remaining boxes
|
||||
rest = order[1:]
|
||||
xx1 = torch.maximum(x1[i], x1[rest])
|
||||
yy1 = torch.maximum(y1[i], y1[rest])
|
||||
xx2 = torch.minimum(x2[i], x2[rest])
|
||||
yy2 = torch.minimum(y2[i], y2[rest])
|
||||
|
||||
# Fast intersection and IoU
|
||||
w = (xx2 - xx1).clamp_(min=0)
|
||||
h = (yy2 - yy1).clamp_(min=0)
|
||||
inter = w * h
|
||||
# Early exit: skip IoU calculation if no intersection
|
||||
if inter.sum() == 0:
|
||||
# No overlaps with current box, keep all remaining boxes
|
||||
order = rest
|
||||
continue
|
||||
iou = inter / (areas[i] + areas[rest] - inter)
|
||||
# Keep boxes with IoU <= threshold
|
||||
order = rest[iou <= iou_threshold]
|
||||
|
||||
return keep[:keep_idx]
|
||||
|
||||
@staticmethod
|
||||
def batched_nms(
|
||||
boxes: torch.Tensor,
|
||||
scores: torch.Tensor,
|
||||
idxs: torch.Tensor,
|
||||
iou_threshold: float,
|
||||
use_fast_nms: bool = False,
|
||||
) -> torch.Tensor:
|
||||
"""Batched NMS for class-aware suppression.
|
||||
|
||||
Args:
|
||||
boxes (torch.Tensor): Bounding boxes with shape (N, 4) in xyxy format.
|
||||
scores (torch.Tensor): Confidence scores with shape (N,).
|
||||
idxs (torch.Tensor): Class indices with shape (N,).
|
||||
iou_threshold (float): IoU threshold for suppression.
|
||||
use_fast_nms (bool): Whether to use the Fast-NMS implementation.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Indices of boxes to keep after NMS.
|
||||
|
||||
Examples:
|
||||
Apply batched NMS across multiple classes
|
||||
>>> boxes = torch.tensor([[0, 0, 10, 10], [5, 5, 15, 15]])
|
||||
>>> scores = torch.tensor([0.9, 0.8])
|
||||
>>> idxs = torch.tensor([0, 1])
|
||||
>>> keep = TorchNMS.batched_nms(boxes, scores, idxs, 0.5)
|
||||
"""
|
||||
if boxes.numel() == 0:
|
||||
return torch.empty((0,), dtype=torch.int64, device=boxes.device)
|
||||
|
||||
# Strategy: offset boxes by class index to prevent cross-class suppression
|
||||
max_coordinate = boxes.max()
|
||||
offsets = idxs.to(boxes) * (max_coordinate + 1)
|
||||
boxes_for_nms = boxes + offsets[:, None]
|
||||
|
||||
return (
|
||||
TorchNMS.fast_nms(boxes_for_nms, scores, iou_threshold)
|
||||
if use_fast_nms
|
||||
else TorchNMS.nms(boxes_for_nms, scores, iou_threshold)
|
||||
)
|
||||
672
ultralytics/utils/ops.py
Executable file
672
ultralytics/utils/ops.py
Executable file
@@ -0,0 +1,672 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from ultralytics.utils import NOT_MACOS14
|
||||
|
||||
|
||||
class Profile(contextlib.ContextDecorator):
|
||||
"""Ultralytics Profile class for timing code execution.
|
||||
|
||||
Use as a decorator with @Profile() or as a context manager with 'with Profile():'. Provides accurate timing
|
||||
measurements with CUDA synchronization support for GPU operations.
|
||||
|
||||
Attributes:
|
||||
t (float): Accumulated time in seconds.
|
||||
device (torch.device): Device used for model inference.
|
||||
cuda (bool): Whether CUDA is being used for timing synchronization.
|
||||
|
||||
Examples:
|
||||
Use as a context manager to time code execution
|
||||
>>> with Profile(device=device) as dt:
|
||||
... pass # slow operation here
|
||||
>>> print(dt) # prints "Elapsed time is 9.5367431640625e-07 s"
|
||||
|
||||
Use as a decorator to time function execution
|
||||
>>> @Profile()
|
||||
... def slow_function():
|
||||
... time.sleep(0.1)
|
||||
"""
|
||||
|
||||
def __init__(self, t: float = 0.0, device: torch.device | None = None):
|
||||
"""Initialize the Profile class.
|
||||
|
||||
Args:
|
||||
t (float): Initial accumulated time in seconds.
|
||||
device (torch.device, optional): Device used for model inference to enable CUDA synchronization.
|
||||
"""
|
||||
self.t = t
|
||||
self.device = device
|
||||
self.cuda = bool(device and str(device).startswith("cuda"))
|
||||
|
||||
def __enter__(self):
|
||||
"""Start timing."""
|
||||
self.start = self.time()
|
||||
return self
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
"""Stop timing."""
|
||||
self.dt = self.time() - self.start # delta-time
|
||||
self.t += self.dt # accumulate dt
|
||||
|
||||
def __str__(self):
|
||||
"""Return a human-readable string representing the accumulated elapsed time."""
|
||||
return f"Elapsed time is {self.t} s"
|
||||
|
||||
def time(self):
|
||||
"""Get current time with CUDA synchronization if applicable."""
|
||||
if self.cuda:
|
||||
torch.cuda.synchronize(self.device)
|
||||
return time.perf_counter()
|
||||
|
||||
|
||||
def segment2box(segment, width: int = 640, height: int = 640):
|
||||
"""Convert segment coordinates to bounding box coordinates.
|
||||
|
||||
Converts a single segment label to a box label by finding the minimum and maximum x and y coordinates. Applies
|
||||
inside-image constraint and clips coordinates when necessary.
|
||||
|
||||
Args:
|
||||
segment (np.ndarray): Segment coordinates in format (N, 2) where N is number of points.
|
||||
width (int): Width of the image in pixels.
|
||||
height (int): Height of the image in pixels.
|
||||
|
||||
Returns:
|
||||
(np.ndarray): Bounding box coordinates in xyxy format [x1, y1, x2, y2].
|
||||
"""
|
||||
x, y = segment.T # segment xy
|
||||
# Clip coordinates if 3 out of 4 sides are outside the image
|
||||
if np.array([x.min() < 0, y.min() < 0, x.max() > width, y.max() > height]).sum() >= 3:
|
||||
x = x.clip(0, width)
|
||||
y = y.clip(0, height)
|
||||
inside = (x > 0) & (y > 0) & (x < width) & (y < height)
|
||||
x = x[inside]
|
||||
y = y[inside]
|
||||
return (
|
||||
np.array([x.min(), y.min(), x.max(), y.max()], dtype=segment.dtype)
|
||||
if any(x)
|
||||
else np.zeros(4, dtype=segment.dtype)
|
||||
) # xyxy
|
||||
|
||||
|
||||
def scale_boxes(img1_shape, boxes, img0_shape, ratio_pad=None, padding: bool = True, xywh: bool = False):
|
||||
"""Rescale bounding boxes from one image shape to another.
|
||||
|
||||
Rescales bounding boxes from img1_shape to img0_shape, accounting for padding and aspect ratio changes. Supports
|
||||
both xyxy and xywh box formats.
|
||||
|
||||
Args:
|
||||
img1_shape (tuple): Shape of the source image (height, width).
|
||||
boxes (torch.Tensor): Bounding boxes to rescale in format (N, 4).
|
||||
img0_shape (tuple): Shape of the target image (height, width).
|
||||
ratio_pad (tuple, optional): Tuple of (ratio, pad) for scaling. If None, calculated from image shapes.
|
||||
padding (bool): Whether boxes are based on YOLO-style augmented images with padding.
|
||||
xywh (bool): Whether box format is xywh (True) or xyxy (False).
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Rescaled bounding boxes in the same format as input.
|
||||
"""
|
||||
if ratio_pad is None: # calculate from img0_shape
|
||||
gain = min(img1_shape[0] / img0_shape[0], img1_shape[1] / img0_shape[1]) # gain = old / new
|
||||
pad_x = round((img1_shape[1] - img0_shape[1] * gain) / 2 - 0.1)
|
||||
pad_y = round((img1_shape[0] - img0_shape[0] * gain) / 2 - 0.1)
|
||||
else:
|
||||
gain = ratio_pad[0][0]
|
||||
pad_x, pad_y = ratio_pad[1]
|
||||
|
||||
if padding:
|
||||
boxes[..., 0] -= pad_x # x padding
|
||||
boxes[..., 1] -= pad_y # y padding
|
||||
if not xywh:
|
||||
boxes[..., 2] -= pad_x # x padding
|
||||
boxes[..., 3] -= pad_y # y padding
|
||||
boxes[..., :4] /= gain
|
||||
return boxes if xywh else clip_boxes(boxes, img0_shape)
|
||||
|
||||
|
||||
def make_divisible(x: int, divisor):
|
||||
"""Return the nearest number that is divisible by the given divisor.
|
||||
|
||||
Args:
|
||||
x (int): The number to make divisible.
|
||||
divisor (int | torch.Tensor): The divisor.
|
||||
|
||||
Returns:
|
||||
(int): The nearest number divisible by the divisor.
|
||||
"""
|
||||
if isinstance(divisor, torch.Tensor):
|
||||
divisor = int(divisor.max()) # to int
|
||||
return math.ceil(x / divisor) * divisor
|
||||
|
||||
|
||||
def clip_boxes(boxes, shape):
|
||||
"""Clip bounding boxes to image boundaries.
|
||||
|
||||
Args:
|
||||
boxes (torch.Tensor | np.ndarray): Bounding boxes to clip.
|
||||
shape (tuple): Image shape as HWC or HW (supports both).
|
||||
|
||||
Returns:
|
||||
(torch.Tensor | np.ndarray): Clipped bounding boxes.
|
||||
"""
|
||||
h, w = shape[:2] # supports both HWC or HW shapes
|
||||
if isinstance(boxes, torch.Tensor): # faster individually
|
||||
if NOT_MACOS14:
|
||||
boxes[..., 0].clamp_(0, w) # x1
|
||||
boxes[..., 1].clamp_(0, h) # y1
|
||||
boxes[..., 2].clamp_(0, w) # x2
|
||||
boxes[..., 3].clamp_(0, h) # y2
|
||||
else: # Apple macOS14 MPS bug https://github.com/ultralytics/ultralytics/pull/21878
|
||||
boxes[..., 0] = boxes[..., 0].clamp(0, w)
|
||||
boxes[..., 1] = boxes[..., 1].clamp(0, h)
|
||||
boxes[..., 2] = boxes[..., 2].clamp(0, w)
|
||||
boxes[..., 3] = boxes[..., 3].clamp(0, h)
|
||||
else: # np.array (faster grouped)
|
||||
boxes[..., [0, 2]] = boxes[..., [0, 2]].clip(0, w) # x1, x2
|
||||
boxes[..., [1, 3]] = boxes[..., [1, 3]].clip(0, h) # y1, y2
|
||||
return boxes
|
||||
|
||||
|
||||
def clip_coords(coords, shape):
|
||||
"""Clip line coordinates to image boundaries.
|
||||
|
||||
Args:
|
||||
coords (torch.Tensor | np.ndarray): Line coordinates to clip.
|
||||
shape (tuple): Image shape as HWC or HW (supports both).
|
||||
|
||||
Returns:
|
||||
(torch.Tensor | np.ndarray): Clipped coordinates.
|
||||
"""
|
||||
h, w = shape[:2] # supports both HWC or HW shapes
|
||||
if isinstance(coords, torch.Tensor):
|
||||
if NOT_MACOS14:
|
||||
coords[..., 0].clamp_(0, w) # x
|
||||
coords[..., 1].clamp_(0, h) # y
|
||||
else: # Apple macOS14 MPS bug https://github.com/ultralytics/ultralytics/pull/21878
|
||||
coords[..., 0] = coords[..., 0].clamp(0, w)
|
||||
coords[..., 1] = coords[..., 1].clamp(0, h)
|
||||
else: # np.array
|
||||
coords[..., 0] = coords[..., 0].clip(0, w) # x
|
||||
coords[..., 1] = coords[..., 1].clip(0, h) # y
|
||||
return coords
|
||||
|
||||
|
||||
def xyxy2xywh(x):
|
||||
"""Convert bounding box coordinates from (x1, y1, x2, y2) format to (x, y, width, height) format where (x1, y1) is
|
||||
the top-left corner and (x2, y2) is the bottom-right corner.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input bounding box coordinates in (x1, y1, x2, y2) format.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Bounding box coordinates in (x, y, width, height) format.
|
||||
"""
|
||||
assert x.shape[-1] == 4, f"input shape last dimension expected 4 but input shape is {x.shape}"
|
||||
y = empty_like(x) # faster than clone/copy
|
||||
x1, y1, x2, y2 = x[..., 0], x[..., 1], x[..., 2], x[..., 3]
|
||||
y[..., 0] = (x1 + x2) / 2 # x center
|
||||
y[..., 1] = (y1 + y2) / 2 # y center
|
||||
y[..., 2] = x2 - x1 # width
|
||||
y[..., 3] = y2 - y1 # height
|
||||
return y
|
||||
|
||||
|
||||
def xywh2xyxy(x):
|
||||
"""Convert bounding box coordinates from (x, y, width, height) format to (x1, y1, x2, y2) format where (x1, y1) is
|
||||
the top-left corner and (x2, y2) is the bottom-right corner. Note: ops per 2 channels faster than per channel.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input bounding box coordinates in (x, y, width, height) format.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Bounding box coordinates in (x1, y1, x2, y2) format.
|
||||
"""
|
||||
assert x.shape[-1] == 4, f"input shape last dimension expected 4 but input shape is {x.shape}"
|
||||
y = empty_like(x) # faster than clone/copy
|
||||
xy = x[..., :2] # centers
|
||||
wh = x[..., 2:] / 2 # half width-height
|
||||
y[..., :2] = xy - wh # top left xy
|
||||
y[..., 2:] = xy + wh # bottom right xy
|
||||
return y
|
||||
|
||||
|
||||
def xywhn2xyxy(x, w: int = 640, h: int = 640, padw: int = 0, padh: int = 0):
|
||||
"""Convert normalized bounding box coordinates to pixel coordinates.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Normalized bounding box coordinates in (x, y, w, h) format.
|
||||
w (int): Image width in pixels.
|
||||
h (int): Image height in pixels.
|
||||
padw (int): Padding width in pixels.
|
||||
padh (int): Padding height in pixels.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Bounding box coordinates in (x1, y1, x2, y2) format.
|
||||
"""
|
||||
assert x.shape[-1] == 4, f"input shape last dimension expected 4 but input shape is {x.shape}"
|
||||
y = empty_like(x) # faster than clone/copy
|
||||
xc, yc, xw, xh = x[..., 0], x[..., 1], x[..., 2], x[..., 3]
|
||||
half_w, half_h = xw / 2, xh / 2
|
||||
y[..., 0] = w * (xc - half_w) + padw # top left x
|
||||
y[..., 1] = h * (yc - half_h) + padh # top left y
|
||||
y[..., 2] = w * (xc + half_w) + padw # bottom right x
|
||||
y[..., 3] = h * (yc + half_h) + padh # bottom right y
|
||||
return y
|
||||
|
||||
|
||||
def xyxy2xywhn(x, w: int = 640, h: int = 640, clip: bool = False, eps: float = 0.0):
|
||||
"""Convert bounding box coordinates from (x1, y1, x2, y2) format to normalized (x, y, width, height) format. x, y,
|
||||
width and height are normalized to image dimensions.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input bounding box coordinates in (x1, y1, x2, y2) format.
|
||||
w (int): Image width in pixels.
|
||||
h (int): Image height in pixels.
|
||||
clip (bool): Whether to clip boxes to image boundaries.
|
||||
eps (float): Minimum value for box width and height.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Normalized bounding box coordinates in (x, y, width, height) format.
|
||||
"""
|
||||
if clip:
|
||||
x = clip_boxes(x, (h - eps, w - eps))
|
||||
assert x.shape[-1] == 4, f"input shape last dimension expected 4 but input shape is {x.shape}"
|
||||
y = empty_like(x) # faster than clone/copy
|
||||
x1, y1, x2, y2 = x[..., 0], x[..., 1], x[..., 2], x[..., 3]
|
||||
y[..., 0] = ((x1 + x2) / 2) / w # x center
|
||||
y[..., 1] = ((y1 + y2) / 2) / h # y center
|
||||
y[..., 2] = (x2 - x1) / w # width
|
||||
y[..., 3] = (y2 - y1) / h # height
|
||||
return y
|
||||
|
||||
|
||||
def xywh2ltwh(x):
|
||||
"""Convert bounding box format from [x, y, w, h] to [x1, y1, w, h] where x1, y1 are top-left coordinates.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input bounding box coordinates in xywh format.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Bounding box coordinates in ltwh format.
|
||||
"""
|
||||
y = x.clone() if isinstance(x, torch.Tensor) else np.copy(x)
|
||||
y[..., 0] = x[..., 0] - x[..., 2] / 2 # top left x
|
||||
y[..., 1] = x[..., 1] - x[..., 3] / 2 # top left y
|
||||
return y
|
||||
|
||||
|
||||
def xyxy2ltwh(x):
|
||||
"""Convert bounding boxes from [x1, y1, x2, y2] to [x1, y1, w, h] format.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input bounding box coordinates in xyxy format.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Bounding box coordinates in ltwh format.
|
||||
"""
|
||||
y = x.clone() if isinstance(x, torch.Tensor) else np.copy(x)
|
||||
y[..., 2] = x[..., 2] - x[..., 0] # width
|
||||
y[..., 3] = x[..., 3] - x[..., 1] # height
|
||||
return y
|
||||
|
||||
|
||||
def ltwh2xywh(x):
|
||||
"""Convert bounding boxes from [x1, y1, w, h] to [x, y, w, h] where xy1=top-left, xy=center.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input bounding box coordinates.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Bounding box coordinates in xywh format.
|
||||
"""
|
||||
y = x.clone() if isinstance(x, torch.Tensor) else np.copy(x)
|
||||
y[..., 0] = x[..., 0] + x[..., 2] / 2 # center x
|
||||
y[..., 1] = x[..., 1] + x[..., 3] / 2 # center y
|
||||
return y
|
||||
|
||||
|
||||
def xyxyxyxy2xywhr(x):
|
||||
"""Convert batched Oriented Bounding Boxes (OBB) from [xy1, xy2, xy3, xy4] to [xywh, rotation] format.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input box corners with shape (N, 8) in [xy1, xy2, xy3, xy4] format.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Converted data in [cx, cy, w, h, rotation] format with shape (N, 5). Rotation
|
||||
values are in radians from [-pi/4, 3pi/4).
|
||||
"""
|
||||
is_torch = isinstance(x, torch.Tensor)
|
||||
points = x.cpu().numpy() if is_torch else x
|
||||
points = points.reshape(len(x), -1, 2)
|
||||
rboxes = []
|
||||
for pts in points:
|
||||
# NOTE: Use cv2.minAreaRect to get accurate xywhr,
|
||||
# especially some objects are cut off by augmentations in dataloader.
|
||||
(cx, cy), (w, h), angle = cv2.minAreaRect(pts)
|
||||
# convert angle to radian and normalize to [-pi/4, 3pi/4)
|
||||
theta = angle / 180 * np.pi
|
||||
if w < h:
|
||||
w, h = h, w
|
||||
theta += np.pi / 2
|
||||
while theta >= 3 * np.pi / 4:
|
||||
theta -= np.pi
|
||||
while theta < -np.pi / 4:
|
||||
theta += np.pi
|
||||
rboxes.append([cx, cy, w, h, theta])
|
||||
return torch.tensor(rboxes, device=x.device, dtype=x.dtype) if is_torch else np.asarray(rboxes)
|
||||
|
||||
|
||||
def xywhr2xyxyxyxy(x):
|
||||
"""Convert batched Oriented Bounding Boxes (OBB) from [xywh, rotation] to [xy1, xy2, xy3, xy4] format.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Boxes in [cx, cy, w, h, rotation] format with shape (N, 5) or (B, N, 5). Rotation
|
||||
values should be in radians from [-pi/4, 3pi/4).
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Converted corner points with shape (N, 4, 2) or (B, N, 4, 2).
|
||||
"""
|
||||
cos, sin, cat, stack = (
|
||||
(torch.cos, torch.sin, torch.cat, torch.stack)
|
||||
if isinstance(x, torch.Tensor)
|
||||
else (np.cos, np.sin, np.concatenate, np.stack)
|
||||
)
|
||||
|
||||
ctr = x[..., :2]
|
||||
w, h, angle = (x[..., i : i + 1] for i in range(2, 5))
|
||||
cos_value, sin_value = cos(angle), sin(angle)
|
||||
vec1 = [w / 2 * cos_value, w / 2 * sin_value]
|
||||
vec2 = [-h / 2 * sin_value, h / 2 * cos_value]
|
||||
vec1 = cat(vec1, -1)
|
||||
vec2 = cat(vec2, -1)
|
||||
pt1 = ctr + vec1 + vec2
|
||||
pt2 = ctr + vec1 - vec2
|
||||
pt3 = ctr - vec1 - vec2
|
||||
pt4 = ctr - vec1 + vec2
|
||||
return stack([pt1, pt2, pt3, pt4], -2)
|
||||
|
||||
|
||||
def ltwh2xyxy(x):
|
||||
"""Convert bounding box from [x1, y1, w, h] to [x1, y1, x2, y2] where xy1=top-left, xy2=bottom-right.
|
||||
|
||||
Args:
|
||||
x (np.ndarray | torch.Tensor): Input bounding box coordinates.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | torch.Tensor): Bounding box coordinates in xyxy format.
|
||||
"""
|
||||
y = x.clone() if isinstance(x, torch.Tensor) else np.copy(x)
|
||||
y[..., 2] = x[..., 2] + x[..., 0] # x2
|
||||
y[..., 3] = x[..., 3] + x[..., 1] # y2
|
||||
return y
|
||||
|
||||
|
||||
def segments2boxes(segments):
|
||||
"""Convert segment coordinates to bounding box labels in xywh format.
|
||||
|
||||
Args:
|
||||
segments (list): List of segments where each segment is a list of points, each point is [x, y] coordinates.
|
||||
|
||||
Returns:
|
||||
(np.ndarray): Bounding box coordinates in xywh format.
|
||||
"""
|
||||
boxes = []
|
||||
for s in segments:
|
||||
x, y = s.T # segment xy
|
||||
boxes.append([x.min(), y.min(), x.max(), y.max()]) # cls, xyxy
|
||||
return xyxy2xywh(np.array(boxes)) # cls, xywh
|
||||
|
||||
|
||||
def resample_segments(segments, n: int = 1000):
|
||||
"""Resample segments to n points each using linear interpolation.
|
||||
|
||||
Args:
|
||||
segments (list): List of (N, 2) arrays where N is the number of points in each segment.
|
||||
n (int): Number of points to resample each segment to.
|
||||
|
||||
Returns:
|
||||
(list): Resampled segments with n points each.
|
||||
"""
|
||||
for i, s in enumerate(segments):
|
||||
if len(s) == n:
|
||||
continue
|
||||
s = np.concatenate((s, s[0:1, :]), axis=0)
|
||||
x = np.linspace(0, len(s) - 1, n - len(s) if len(s) < n else n)
|
||||
xp = np.arange(len(s))
|
||||
x = np.insert(x, np.searchsorted(x, xp), xp) if len(s) < n else x
|
||||
segments[i] = (
|
||||
np.concatenate([np.interp(x, xp, s[:, i]) for i in range(2)], dtype=np.float32).reshape(2, -1).T
|
||||
) # segment xy
|
||||
return segments
|
||||
|
||||
|
||||
def crop_mask(masks: torch.Tensor, boxes: torch.Tensor) -> torch.Tensor:
|
||||
"""Crop masks to bounding box regions.
|
||||
|
||||
Args:
|
||||
masks (torch.Tensor): Masks with shape (N, H, W).
|
||||
boxes (torch.Tensor): Bounding box coordinates with shape (N, 4) in xyxy pixel format.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Cropped masks.
|
||||
"""
|
||||
if boxes.device != masks.device:
|
||||
boxes = boxes.to(masks.device)
|
||||
n, h, w = masks.shape
|
||||
if n < 50 and not masks.is_cuda: # faster for fewer masks (predict)
|
||||
for i, (x1, y1, x2, y2) in enumerate(boxes.round().int()):
|
||||
masks[i, :y1] = 0
|
||||
masks[i, y2:] = 0
|
||||
masks[i, :, :x1] = 0
|
||||
masks[i, :, x2:] = 0
|
||||
return masks
|
||||
else: # faster for more masks (val)
|
||||
x1, y1, x2, y2 = torch.chunk(boxes[:, :, None], 4, 1) # x1 shape(n,1,1)
|
||||
r = torch.arange(w, device=masks.device, dtype=x1.dtype)[None, None, :] # rows shape(1,1,w)
|
||||
c = torch.arange(h, device=masks.device, dtype=x1.dtype)[None, :, None] # cols shape(1,h,1)
|
||||
return masks * ((r >= x1) * (r < x2) * (c >= y1) * (c < y2))
|
||||
|
||||
|
||||
def process_mask(protos, masks_in, bboxes, shape, upsample: bool = False):
|
||||
"""Apply masks to bounding boxes using mask head output.
|
||||
|
||||
Args:
|
||||
protos (torch.Tensor): Mask prototypes with shape (mask_dim, mask_h, mask_w).
|
||||
masks_in (torch.Tensor): Mask coefficients with shape (N, mask_dim) where N is number of masks after NMS.
|
||||
bboxes (torch.Tensor): Bounding boxes with shape (N, 4) where N is number of masks after NMS.
|
||||
shape (tuple): Input image size as (height, width).
|
||||
upsample (bool): Whether to upsample masks to original image size.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): A binary mask tensor of shape [n, h, w], where n is the number of masks after NMS, and h and w
|
||||
are the height and width of the input image. The mask is applied to the bounding boxes.
|
||||
"""
|
||||
c, mh, mw = protos.shape # CHW
|
||||
masks = (masks_in @ protos.float().view(c, -1)).view(-1, mh, mw) # NHW
|
||||
|
||||
width_ratio = mw / shape[1]
|
||||
height_ratio = mh / shape[0]
|
||||
ratios = torch.tensor([[width_ratio, height_ratio, width_ratio, height_ratio]], device=bboxes.device)
|
||||
|
||||
masks = crop_mask(masks, boxes=bboxes * ratios) # NHW
|
||||
if upsample:
|
||||
masks = F.interpolate(masks[None], shape, mode="bilinear")[0] # NHW
|
||||
return masks.gt_(0.0).byte()
|
||||
|
||||
|
||||
def process_mask_native(protos, masks_in, bboxes, shape):
|
||||
"""Apply masks to bounding boxes using mask head output with native upsampling.
|
||||
|
||||
Args:
|
||||
protos (torch.Tensor): Mask prototypes with shape (mask_dim, mask_h, mask_w).
|
||||
masks_in (torch.Tensor): Mask coefficients with shape (N, mask_dim) where N is number of masks after NMS.
|
||||
bboxes (torch.Tensor): Bounding boxes with shape (N, 4) where N is number of masks after NMS.
|
||||
shape (tuple): Input image size as (height, width).
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Binary mask tensor with shape (N, H, W).
|
||||
"""
|
||||
c, mh, mw = protos.shape # CHW
|
||||
masks = (masks_in @ protos.float().view(c, -1)).view(-1, mh, mw)
|
||||
masks = scale_masks(masks[None], shape)[0] # NHW
|
||||
masks = crop_mask(masks, bboxes) # NHW
|
||||
return masks.gt_(0.0).byte()
|
||||
|
||||
|
||||
def scale_masks(
|
||||
masks: torch.Tensor,
|
||||
shape: tuple[int, int],
|
||||
ratio_pad: tuple[tuple[int, int], tuple[int, int]] | None = None,
|
||||
padding: bool = True,
|
||||
) -> torch.Tensor:
|
||||
"""Rescale segment masks to target shape.
|
||||
|
||||
Args:
|
||||
masks (torch.Tensor): Masks with shape (N, C, H, W).
|
||||
shape (tuple[int, int]): Target height and width as (height, width).
|
||||
ratio_pad (tuple, optional): Ratio and padding values as ((ratio_h, ratio_w), (pad_w, pad_h)).
|
||||
padding (bool): Whether masks are based on YOLO-style augmented images with padding.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Rescaled masks.
|
||||
"""
|
||||
im1_h, im1_w = masks.shape[2:]
|
||||
im0_h, im0_w = shape[:2]
|
||||
if im1_h == im0_h and im1_w == im0_w:
|
||||
return masks
|
||||
|
||||
if ratio_pad is None: # calculate from im0_shape
|
||||
gain = min(im1_h / im0_h, im1_w / im0_w) # gain = old / new
|
||||
pad_w, pad_h = (im1_w - im0_w * gain), (im1_h - im0_h * gain) # wh padding
|
||||
if padding:
|
||||
pad_w /= 2
|
||||
pad_h /= 2
|
||||
else:
|
||||
pad_w, pad_h = ratio_pad[1]
|
||||
top, left = (round(pad_h - 0.1), round(pad_w - 0.1)) if padding else (0, 0)
|
||||
bottom = im1_h - round(pad_h + 0.1)
|
||||
right = im1_w - round(pad_w + 0.1)
|
||||
return F.interpolate(masks[..., top:bottom, left:right].float(), shape, mode="bilinear") # NCHW masks
|
||||
|
||||
|
||||
def scale_coords(img1_shape, coords, img0_shape, ratio_pad=None, normalize: bool = False, padding: bool = True):
|
||||
"""Rescale segment coordinates from img1_shape to img0_shape.
|
||||
|
||||
Args:
|
||||
img1_shape (tuple): Source image shape as HWC or HW (supports both).
|
||||
coords (torch.Tensor): Coordinates to scale with shape (N, 2).
|
||||
img0_shape (tuple): Image 0 shape as HWC or HW (supports both).
|
||||
ratio_pad (tuple, optional): Ratio and padding values as ((ratio_h, ratio_w), (pad_w, pad_h)).
|
||||
normalize (bool): Whether to normalize coordinates to range [0, 1].
|
||||
padding (bool): Whether coordinates are based on YOLO-style augmented images with padding.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Scaled coordinates.
|
||||
"""
|
||||
img0_h, img0_w = img0_shape[:2] # supports both HWC or HW shapes
|
||||
if ratio_pad is None: # calculate from img0_shape
|
||||
img1_h, img1_w = img1_shape[:2] # supports both HWC or HW shapes
|
||||
gain = min(img1_h / img0_h, img1_w / img0_w) # gain = old / new
|
||||
pad = (img1_w - img0_w * gain) / 2, (img1_h - img0_h * gain) / 2 # wh padding
|
||||
else:
|
||||
gain = ratio_pad[0][0]
|
||||
pad = ratio_pad[1]
|
||||
|
||||
if padding:
|
||||
coords[..., 0] -= pad[0] # x padding
|
||||
coords[..., 1] -= pad[1] # y padding
|
||||
coords[..., 0] /= gain
|
||||
coords[..., 1] /= gain
|
||||
coords = clip_coords(coords, img0_shape)
|
||||
if normalize:
|
||||
coords[..., 0] /= img0_w # width
|
||||
coords[..., 1] /= img0_h # height
|
||||
return coords
|
||||
|
||||
|
||||
def regularize_rboxes(rboxes):
|
||||
"""Regularize rotated bounding boxes to range [0, pi/2).
|
||||
|
||||
Args:
|
||||
rboxes (torch.Tensor): Input rotated boxes with shape (N, 5) in xywhr format.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Regularized rotated boxes.
|
||||
"""
|
||||
x, y, w, h, t = rboxes.unbind(dim=-1)
|
||||
# Swap edge if t >= pi/2 while not being symmetrically opposite
|
||||
swap = t % math.pi >= math.pi / 2
|
||||
w_ = torch.where(swap, h, w)
|
||||
h_ = torch.where(swap, w, h)
|
||||
t = t % (math.pi / 2)
|
||||
return torch.stack([x, y, w_, h_, t], dim=-1) # regularized boxes
|
||||
|
||||
|
||||
def masks2segments(masks: np.ndarray | torch.Tensor, strategy: str = "all") -> list[np.ndarray]:
|
||||
"""Convert masks to segments using contour detection.
|
||||
|
||||
Args:
|
||||
masks (np.ndarray | torch.Tensor): Binary masks with shape (N, H, W).
|
||||
strategy (str): Segmentation strategy, either 'all' or 'largest'.
|
||||
|
||||
Returns:
|
||||
(list): List of segment masks as float32 arrays.
|
||||
"""
|
||||
from ultralytics.data.converter import merge_multi_segment
|
||||
|
||||
masks = masks.astype("uint8") if isinstance(masks, np.ndarray) else masks.byte().cpu().numpy()
|
||||
segments = []
|
||||
for x in np.ascontiguousarray(masks):
|
||||
c = cv2.findContours(x, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
|
||||
if c:
|
||||
if strategy == "all": # merge and concatenate all segments
|
||||
c = (
|
||||
np.concatenate(merge_multi_segment([x.reshape(-1, 2) for x in c]))
|
||||
if len(c) > 1
|
||||
else c[0].reshape(-1, 2)
|
||||
)
|
||||
elif strategy == "largest": # select largest segment
|
||||
c = np.array(c[np.array([len(x) for x in c]).argmax()]).reshape(-1, 2)
|
||||
else:
|
||||
c = np.zeros((0, 2)) # no segments found
|
||||
segments.append(c.astype("float32"))
|
||||
return segments
|
||||
|
||||
|
||||
def convert_torch2numpy_batch(batch: torch.Tensor) -> np.ndarray:
|
||||
"""Convert a batch of FP32 torch tensors to NumPy uint8 arrays, changing from BCHW to BHWC layout.
|
||||
|
||||
Args:
|
||||
batch (torch.Tensor): Input tensor batch with shape (Batch, Channels, Height, Width) and dtype torch.float32.
|
||||
|
||||
Returns:
|
||||
(np.ndarray): Output NumPy array batch with shape (Batch, Height, Width, Channels) and dtype uint8.
|
||||
"""
|
||||
return (batch.permute(0, 2, 3, 1).contiguous() * 255).clamp(0, 255).byte().cpu().numpy()
|
||||
|
||||
|
||||
def clean_str(s):
|
||||
"""Clean a string by replacing special characters with '_' character.
|
||||
|
||||
Args:
|
||||
s (str): A string needing special characters replaced.
|
||||
|
||||
Returns:
|
||||
(str): A string with special characters replaced by an underscore _.
|
||||
"""
|
||||
return re.sub(pattern="[|@#!¡·$€%&()=?¿^*;:,¨`><+]", repl="_", string=s)
|
||||
|
||||
|
||||
def empty_like(x):
|
||||
"""Create empty torch.Tensor or np.ndarray with same shape and dtype as input."""
|
||||
return torch.empty_like(x, dtype=x.dtype) if isinstance(x, torch.Tensor) else np.empty_like(x, dtype=x.dtype)
|
||||
266
ultralytics/utils/patches.py
Executable file
266
ultralytics/utils/patches.py
Executable file
@@ -0,0 +1,266 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
"""Monkey patches to update/extend functionality of existing functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from copy import copy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
# OpenCV Multilanguage-friendly functions ------------------------------------------------------------------------------
|
||||
_imshow = cv2.imshow # copy to avoid recursion errors
|
||||
|
||||
|
||||
def imread(filename: str, flags: int = cv2.IMREAD_COLOR) -> np.ndarray | None:
|
||||
"""Read an image from a file with multilanguage filename support.
|
||||
|
||||
Args:
|
||||
filename (str): Path to the file to read.
|
||||
flags (int, optional): Flag that can take values of cv2.IMREAD_*. Controls how the image is read.
|
||||
|
||||
Returns:
|
||||
(np.ndarray | None): The read image array, or None if reading fails.
|
||||
|
||||
Examples:
|
||||
>>> img = imread("path/to/image.jpg")
|
||||
>>> img = imread("path/to/image.jpg", cv2.IMREAD_GRAYSCALE)
|
||||
"""
|
||||
file_bytes = np.fromfile(filename, np.uint8)
|
||||
if filename.endswith((".tiff", ".tif")):
|
||||
success, frames = cv2.imdecodemulti(file_bytes, cv2.IMREAD_UNCHANGED)
|
||||
if success:
|
||||
# Handle multi-frame TIFFs and color images
|
||||
return frames[0] if len(frames) == 1 and frames[0].ndim == 3 else np.stack(frames, axis=2)
|
||||
return None
|
||||
else:
|
||||
im = cv2.imdecode(file_bytes, flags)
|
||||
# Fallback for formats OpenCV imdecode may not support (AVIF, HEIC)
|
||||
if im is None and filename.lower().endswith((".avif", ".heic")):
|
||||
im = _imread_pil(filename, flags)
|
||||
return im[..., None] if im is not None and im.ndim == 2 else im # Always ensure 3 dimensions
|
||||
|
||||
|
||||
# PIL patches ---------------------------------------------------------------------------------------------------------
|
||||
_image_open = Image.open # copy to avoid recursion errors
|
||||
_pil_plugins_registered = False
|
||||
|
||||
|
||||
def image_open(filename, *args, **kwargs):
|
||||
"""Open an image with PIL, lazily registering the HEIF plugin on first failure.
|
||||
|
||||
This monkey-patches PIL.Image.open to add HEIC/HEIF support via pi-heif (lightweight, decode-only), avoiding the
|
||||
~800ms startup cost of importing the package unless actually needed. AVIF is supported natively by Pillow 12+ and
|
||||
does not require a plugin.
|
||||
|
||||
Args:
|
||||
filename (str): Path to the image file.
|
||||
*args (Any): Additional positional arguments passed to PIL.Image.open.
|
||||
**kwargs (Any): Additional keyword arguments passed to PIL.Image.open.
|
||||
|
||||
Returns:
|
||||
(PIL.Image.Image): The opened PIL image.
|
||||
"""
|
||||
global _pil_plugins_registered
|
||||
if _pil_plugins_registered:
|
||||
return _image_open(filename, *args, **kwargs)
|
||||
try:
|
||||
return _image_open(filename, *args, **kwargs)
|
||||
except Exception as e:
|
||||
suffix = Path(filename).suffix.lower() if isinstance(filename, (str, Path)) else ""
|
||||
if suffix not in {".heic", ".heif", ".hif"}:
|
||||
raise e
|
||||
from ultralytics.utils.checks import check_requirements
|
||||
|
||||
check_requirements("pi-heif")
|
||||
from pi_heif import register_heif_opener
|
||||
|
||||
register_heif_opener()
|
||||
_pil_plugins_registered = True
|
||||
return _image_open(filename, *args, **kwargs)
|
||||
|
||||
|
||||
Image.open = image_open # apply patch
|
||||
|
||||
|
||||
def _imread_pil(filename: str, flags: int = cv2.IMREAD_COLOR) -> np.ndarray | None:
|
||||
"""Read an image using PIL as fallback for formats not supported by OpenCV.
|
||||
|
||||
Args:
|
||||
filename (str): Path to the file to read.
|
||||
flags (int, optional): OpenCV imread flags (used to determine grayscale conversion).
|
||||
|
||||
Returns:
|
||||
(np.ndarray | None): The read image array in BGR format, or None if reading fails.
|
||||
"""
|
||||
try:
|
||||
with Image.open(filename) as img:
|
||||
if flags == cv2.IMREAD_GRAYSCALE:
|
||||
return np.asarray(img.convert("L"))
|
||||
return cv2.cvtColor(np.asarray(img.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def imwrite(filename: str, img: np.ndarray, params: list[int] | None = None) -> bool:
|
||||
"""Write an image to a file with multilanguage filename support.
|
||||
|
||||
Args:
|
||||
filename (str): Path to the file to write.
|
||||
img (np.ndarray): Image to write.
|
||||
params (list[int], optional): Additional parameters for image encoding.
|
||||
|
||||
Returns:
|
||||
(bool): True if the file was written successfully, False otherwise.
|
||||
|
||||
Examples:
|
||||
>>> import numpy as np
|
||||
>>> img = np.zeros((100, 100, 3), dtype=np.uint8) # Create a black image
|
||||
>>> success = imwrite("output.jpg", img) # Write image to file
|
||||
>>> print(success)
|
||||
True
|
||||
"""
|
||||
try:
|
||||
cv2.imencode(Path(filename).suffix, img, params)[1].tofile(filename)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def imshow(winname: str, mat: np.ndarray) -> None:
|
||||
"""Display an image in the specified window with multilanguage window name support.
|
||||
|
||||
This function is a wrapper around OpenCV's imshow function that displays an image in a named window. It handles
|
||||
multilanguage window names by encoding them properly for OpenCV compatibility.
|
||||
|
||||
Args:
|
||||
winname (str): Name of the window where the image will be displayed. If a window with this name already exists,
|
||||
the image will be displayed in that window.
|
||||
mat (np.ndarray): Image to be shown. Should be a valid numpy array representing an image.
|
||||
|
||||
Examples:
|
||||
>>> import numpy as np
|
||||
>>> img = np.zeros((300, 300, 3), dtype=np.uint8) # Create a black image
|
||||
>>> img[:100, :100] = [255, 0, 0] # Add a blue square
|
||||
>>> imshow("Example Window", img) # Display the image
|
||||
"""
|
||||
_imshow(winname.encode("unicode_escape").decode(), mat)
|
||||
|
||||
|
||||
# PyTorch functions ----------------------------------------------------------------------------------------------------
|
||||
_torch_save = torch.save
|
||||
|
||||
|
||||
def torch_load(*args, **kwargs):
|
||||
"""Load a PyTorch model with updated arguments to avoid warnings.
|
||||
|
||||
This function wraps torch.load and adds the 'weights_only' argument for PyTorch 1.13.0+ to prevent warnings.
|
||||
|
||||
Args:
|
||||
*args (Any): Variable length argument list to pass to torch.load.
|
||||
**kwargs (Any): Arbitrary keyword arguments to pass to torch.load.
|
||||
|
||||
Returns:
|
||||
(Any): The loaded PyTorch object.
|
||||
|
||||
Notes:
|
||||
For PyTorch versions 1.13 and above, this function automatically sets `weights_only=False` if the argument is
|
||||
not provided, to avoid deprecation warnings.
|
||||
"""
|
||||
from ultralytics.utils.torch_utils import TORCH_1_13
|
||||
|
||||
if TORCH_1_13 and "weights_only" not in kwargs:
|
||||
kwargs["weights_only"] = False
|
||||
|
||||
return torch.load(*args, **kwargs)
|
||||
|
||||
|
||||
def torch_save(*args, **kwargs):
|
||||
"""Save PyTorch objects with retry mechanism for robustness.
|
||||
|
||||
This function wraps torch.save with 3 retries and exponential backoff in case of save failures, which can occur due
|
||||
to device flushing delays or antivirus scanning.
|
||||
|
||||
Args:
|
||||
*args (Any): Positional arguments to pass to torch.save.
|
||||
**kwargs (Any): Keyword arguments to pass to torch.save.
|
||||
|
||||
Examples:
|
||||
>>> model = torch.nn.Linear(10, 1)
|
||||
>>> torch_save(model.state_dict(), "model.pt")
|
||||
"""
|
||||
for i in range(4): # 3 retries
|
||||
try:
|
||||
return _torch_save(*args, **kwargs)
|
||||
except RuntimeError as e: # Unable to save, possibly waiting for device to flush or antivirus scan
|
||||
if i == 3:
|
||||
raise e
|
||||
time.sleep((2**i) / 2) # Exponential backoff: 0.5s, 1.0s, 2.0s
|
||||
|
||||
|
||||
@contextmanager
|
||||
def arange_patch(args):
|
||||
"""Workaround for ONNX torch.arange incompatibility with FP16.
|
||||
|
||||
https://github.com/pytorch/pytorch/issues/148041.
|
||||
"""
|
||||
if args.dynamic and args.half and args.format == "onnx":
|
||||
func = torch.arange
|
||||
|
||||
def arange(*args, dtype=None, **kwargs):
|
||||
"""Wrap torch.arange to cast dtype after creation instead of passing it directly."""
|
||||
return func(*args, **kwargs).to(dtype) # cast to dtype instead of passing dtype
|
||||
|
||||
torch.arange = arange # patch
|
||||
yield
|
||||
torch.arange = func # unpatch
|
||||
else:
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def onnx_export_patch():
|
||||
"""Workaround for ONNX export issues in PyTorch 2.9+ with Dynamo enabled."""
|
||||
from ultralytics.utils.torch_utils import TORCH_2_9
|
||||
|
||||
if TORCH_2_9:
|
||||
func = torch.onnx.export
|
||||
|
||||
def torch_export(*args, **kwargs):
|
||||
"""Export model to ONNX format with Dynamo disabled for compatibility."""
|
||||
return func(*args, **kwargs, dynamo=False)
|
||||
|
||||
torch.onnx.export = torch_export # patch
|
||||
yield
|
||||
torch.onnx.export = func # unpatch
|
||||
else:
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def override_configs(args, overrides: dict[str, Any] | None = None):
|
||||
"""Context manager to temporarily override configurations in args.
|
||||
|
||||
Args:
|
||||
args (IterableSimpleNamespace): Original configuration arguments.
|
||||
overrides (dict[str, Any] | None): Dictionary of overrides to apply.
|
||||
|
||||
Yields:
|
||||
(IterableSimpleNamespace): Configuration arguments with overrides applied.
|
||||
"""
|
||||
if overrides:
|
||||
original_args = copy(args)
|
||||
for key, value in overrides.items():
|
||||
setattr(args, key, value)
|
||||
try:
|
||||
yield args
|
||||
finally:
|
||||
args.__dict__.update(original_args.__dict__)
|
||||
else:
|
||||
yield args
|
||||
1047
ultralytics/utils/plotting.py
Executable file
1047
ultralytics/utils/plotting.py
Executable file
File diff suppressed because it is too large
Load Diff
2776
ultralytics/utils/plotting_3d.py
Executable file
2776
ultralytics/utils/plotting_3d.py
Executable file
File diff suppressed because it is too large
Load Diff
493
ultralytics/utils/tal.py
Executable file
493
ultralytics/utils/tal.py
Executable file
@@ -0,0 +1,493 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from . import LOGGER
|
||||
from .metrics import bbox_iou, probiou
|
||||
from .ops import xywh2xyxy, xywhr2xyxyxyxy, xyxy2xywh
|
||||
from .torch_utils import TORCH_1_11
|
||||
|
||||
|
||||
class TaskAlignedAssigner(nn.Module):
|
||||
"""A task-aligned assigner for object detection.
|
||||
|
||||
This class assigns ground-truth (gt) objects to anchors based on the task-aligned metric, which combines both
|
||||
classification and localization information.
|
||||
|
||||
Attributes:
|
||||
topk (int): The number of top candidates to consider.
|
||||
topk2 (int): Secondary topk value for additional filtering.
|
||||
num_classes (int): The number of object classes.
|
||||
alpha (float): The alpha parameter for the classification component of the task-aligned metric.
|
||||
beta (float): The beta parameter for the localization component of the task-aligned metric.
|
||||
stride (list): List of stride values for different feature levels.
|
||||
stride_val (int): The stride value used for select_candidates_in_gts.
|
||||
eps (float): A small value to prevent division by zero.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
topk: int = 13,
|
||||
num_classes: int = 80,
|
||||
alpha: float = 1.0,
|
||||
beta: float = 6.0,
|
||||
stride: list = [8, 16, 32],
|
||||
eps: float = 1e-9,
|
||||
topk2=None,
|
||||
):
|
||||
"""Initialize a TaskAlignedAssigner object with customizable hyperparameters.
|
||||
|
||||
Args:
|
||||
topk (int, optional): The number of top candidates to consider.
|
||||
num_classes (int, optional): The number of object classes.
|
||||
alpha (float, optional): The alpha parameter for the classification component of the task-aligned metric.
|
||||
beta (float, optional): The beta parameter for the localization component of the task-aligned metric.
|
||||
stride (list, optional): List of stride values for different feature levels.
|
||||
eps (float, optional): A small value to prevent division by zero.
|
||||
topk2 (int, optional): Secondary topk value for additional filtering.
|
||||
"""
|
||||
super().__init__()
|
||||
self.topk = topk
|
||||
self.topk2 = topk2 or topk
|
||||
self.num_classes = num_classes
|
||||
self.alpha = alpha
|
||||
self.beta = beta
|
||||
self.stride = stride
|
||||
self.stride_val = self.stride[1] if len(self.stride) > 1 else self.stride[0]
|
||||
self.eps = eps
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, pd_scores, pd_bboxes, anc_points, gt_labels, gt_bboxes, mask_gt):
|
||||
"""Compute the task-aligned assignment.
|
||||
|
||||
Args:
|
||||
pd_scores (torch.Tensor): Predicted classification scores with shape (bs, num_total_anchors, num_classes).
|
||||
pd_bboxes (torch.Tensor): Predicted bounding boxes with shape (bs, num_total_anchors, 4).
|
||||
anc_points (torch.Tensor): Anchor points with shape (num_total_anchors, 2).
|
||||
gt_labels (torch.Tensor): Ground truth labels with shape (bs, n_max_boxes, 1).
|
||||
gt_bboxes (torch.Tensor): Ground truth boxes with shape (bs, n_max_boxes, 4).
|
||||
mask_gt (torch.Tensor): Mask for valid ground truth boxes with shape (bs, n_max_boxes, 1).
|
||||
|
||||
Returns:
|
||||
target_labels (torch.Tensor): Target labels with shape (bs, num_total_anchors).
|
||||
target_bboxes (torch.Tensor): Target bounding boxes with shape (bs, num_total_anchors, 4).
|
||||
target_scores (torch.Tensor): Target scores with shape (bs, num_total_anchors, num_classes).
|
||||
fg_mask (torch.Tensor): Foreground mask with shape (bs, num_total_anchors).
|
||||
target_gt_idx (torch.Tensor): Target ground truth indices with shape (bs, num_total_anchors).
|
||||
|
||||
References:
|
||||
https://github.com/Nioolek/PPYOLOE_pytorch/blob/master/ppyoloe/assigner/tal_assigner.py
|
||||
"""
|
||||
self.bs = pd_scores.shape[0]
|
||||
self.n_max_boxes = gt_bboxes.shape[1]
|
||||
device = gt_bboxes.device
|
||||
|
||||
if self.n_max_boxes == 0:
|
||||
return (
|
||||
torch.full_like(pd_scores[..., 0], self.num_classes),
|
||||
torch.zeros_like(pd_bboxes),
|
||||
torch.zeros_like(pd_scores),
|
||||
torch.zeros_like(pd_scores[..., 0]),
|
||||
torch.zeros_like(pd_scores[..., 0]),
|
||||
)
|
||||
|
||||
try:
|
||||
return self._forward(pd_scores, pd_bboxes, anc_points, gt_labels, gt_bboxes, mask_gt)
|
||||
except RuntimeError as e:
|
||||
if "out of memory" in str(e).lower():
|
||||
# Move tensors to CPU, compute, then move back to original device
|
||||
LOGGER.warning("CUDA OutOfMemoryError in TaskAlignedAssigner, using CPU")
|
||||
cpu_tensors = [t.cpu() for t in (pd_scores, pd_bboxes, anc_points, gt_labels, gt_bboxes, mask_gt)]
|
||||
result = self._forward(*cpu_tensors)
|
||||
return tuple(t.to(device) for t in result)
|
||||
raise
|
||||
|
||||
def _forward(self, pd_scores, pd_bboxes, anc_points, gt_labels, gt_bboxes, mask_gt):
|
||||
"""Compute the task-aligned assignment.
|
||||
|
||||
Args:
|
||||
pd_scores (torch.Tensor): Predicted classification scores with shape (bs, num_total_anchors, num_classes).
|
||||
pd_bboxes (torch.Tensor): Predicted bounding boxes with shape (bs, num_total_anchors, 4).
|
||||
anc_points (torch.Tensor): Anchor points with shape (num_total_anchors, 2).
|
||||
gt_labels (torch.Tensor): Ground truth labels with shape (bs, n_max_boxes, 1).
|
||||
gt_bboxes (torch.Tensor): Ground truth boxes with shape (bs, n_max_boxes, 4).
|
||||
mask_gt (torch.Tensor): Mask for valid ground truth boxes with shape (bs, n_max_boxes, 1).
|
||||
|
||||
Returns:
|
||||
target_labels (torch.Tensor): Target labels with shape (bs, num_total_anchors).
|
||||
target_bboxes (torch.Tensor): Target bounding boxes with shape (bs, num_total_anchors, 4).
|
||||
target_scores (torch.Tensor): Target scores with shape (bs, num_total_anchors, num_classes).
|
||||
fg_mask (torch.Tensor): Foreground mask with shape (bs, num_total_anchors).
|
||||
target_gt_idx (torch.Tensor): Target ground truth indices with shape (bs, num_total_anchors).
|
||||
"""
|
||||
mask_pos, align_metric, overlaps = self.get_pos_mask(
|
||||
pd_scores, pd_bboxes, gt_labels, gt_bboxes, anc_points, mask_gt
|
||||
)
|
||||
|
||||
target_gt_idx, fg_mask, mask_pos = self.select_highest_overlaps(
|
||||
mask_pos, overlaps, self.n_max_boxes, align_metric
|
||||
)
|
||||
|
||||
# Assigned target
|
||||
target_labels, target_bboxes, target_scores = self.get_targets(gt_labels, gt_bboxes, target_gt_idx, fg_mask)
|
||||
|
||||
# Normalize
|
||||
align_metric *= mask_pos
|
||||
pos_align_metrics = align_metric.amax(dim=-1, keepdim=True) # b, max_num_obj
|
||||
pos_overlaps = (overlaps * mask_pos).amax(dim=-1, keepdim=True) # b, max_num_obj
|
||||
norm_align_metric = (align_metric * pos_overlaps / (pos_align_metrics + self.eps)).amax(-2).unsqueeze(-1)
|
||||
target_scores = target_scores * norm_align_metric
|
||||
|
||||
return target_labels, target_bboxes, target_scores, fg_mask.bool(), target_gt_idx
|
||||
|
||||
def get_pos_mask(self, pd_scores, pd_bboxes, gt_labels, gt_bboxes, anc_points, mask_gt):
|
||||
"""Get positive mask for each ground truth box.
|
||||
|
||||
Args:
|
||||
pd_scores (torch.Tensor): Predicted classification scores with shape (bs, num_total_anchors, num_classes).
|
||||
pd_bboxes (torch.Tensor): Predicted bounding boxes with shape (bs, num_total_anchors, 4).
|
||||
gt_labels (torch.Tensor): Ground truth labels with shape (bs, n_max_boxes, 1).
|
||||
gt_bboxes (torch.Tensor): Ground truth boxes with shape (bs, n_max_boxes, 4).
|
||||
anc_points (torch.Tensor): Anchor points with shape (num_total_anchors, 2).
|
||||
mask_gt (torch.Tensor): Mask for valid ground truth boxes with shape (bs, n_max_boxes, 1).
|
||||
|
||||
Returns:
|
||||
mask_pos (torch.Tensor): Positive mask with shape (bs, max_num_obj, h*w).
|
||||
align_metric (torch.Tensor): Alignment metric with shape (bs, max_num_obj, h*w).
|
||||
overlaps (torch.Tensor): Overlaps between predicted vs ground truth boxes with shape (bs, max_num_obj, h*w).
|
||||
"""
|
||||
mask_in_gts = self.select_candidates_in_gts(anc_points, gt_bboxes, mask_gt)
|
||||
# Get anchor_align metric, (b, max_num_obj, h*w)
|
||||
align_metric, overlaps = self.get_box_metrics(pd_scores, pd_bboxes, gt_labels, gt_bboxes, mask_in_gts * mask_gt)
|
||||
# Get topk_metric mask, (b, max_num_obj, h*w)
|
||||
mask_topk = self.select_topk_candidates(align_metric, topk_mask=mask_gt.expand(-1, -1, self.topk).bool())
|
||||
# Merge all mask to a final mask, (b, max_num_obj, h*w)
|
||||
mask_pos = mask_topk * mask_in_gts * mask_gt
|
||||
|
||||
return mask_pos, align_metric, overlaps
|
||||
|
||||
def get_box_metrics(self, pd_scores, pd_bboxes, gt_labels, gt_bboxes, mask_gt):
|
||||
"""Compute alignment metric given predicted and ground truth bounding boxes.
|
||||
|
||||
Args:
|
||||
pd_scores (torch.Tensor): Predicted classification scores with shape (bs, num_total_anchors, num_classes).
|
||||
pd_bboxes (torch.Tensor): Predicted bounding boxes with shape (bs, num_total_anchors, 4).
|
||||
gt_labels (torch.Tensor): Ground truth labels with shape (bs, n_max_boxes, 1).
|
||||
gt_bboxes (torch.Tensor): Ground truth boxes with shape (bs, n_max_boxes, 4).
|
||||
mask_gt (torch.Tensor): Mask for valid ground truth boxes with shape (bs, n_max_boxes, h*w).
|
||||
|
||||
Returns:
|
||||
align_metric (torch.Tensor): Alignment metric combining classification and localization.
|
||||
overlaps (torch.Tensor): IoU overlaps between predicted and ground truth boxes.
|
||||
"""
|
||||
na = pd_bboxes.shape[-2]
|
||||
mask_gt = mask_gt.bool() # b, max_num_obj, h*w
|
||||
overlaps = torch.zeros([self.bs, self.n_max_boxes, na], dtype=pd_bboxes.dtype, device=pd_bboxes.device)
|
||||
bbox_scores = torch.zeros([self.bs, self.n_max_boxes, na], dtype=pd_scores.dtype, device=pd_scores.device)
|
||||
|
||||
ind = torch.zeros([2, self.bs, self.n_max_boxes], dtype=torch.long) # 2, b, max_num_obj
|
||||
ind[0] = torch.arange(end=self.bs).view(-1, 1).expand(-1, self.n_max_boxes) # b, max_num_obj
|
||||
ind[1] = gt_labels.squeeze(-1) # b, max_num_obj
|
||||
# Get the scores of each grid for each gt cls
|
||||
bbox_scores[mask_gt] = pd_scores[ind[0], :, ind[1]][mask_gt] # b, max_num_obj, h*w
|
||||
|
||||
# (b, max_num_obj, 1, 4), (b, 1, h*w, 4)
|
||||
pd_boxes = pd_bboxes.unsqueeze(1).expand(-1, self.n_max_boxes, -1, -1)[mask_gt]
|
||||
gt_boxes = gt_bboxes.unsqueeze(2).expand(-1, -1, na, -1)[mask_gt]
|
||||
overlaps[mask_gt] = self.iou_calculation(gt_boxes, pd_boxes)
|
||||
|
||||
align_metric = bbox_scores.pow(self.alpha) * overlaps.pow(self.beta)
|
||||
return align_metric, overlaps
|
||||
|
||||
def iou_calculation(self, gt_bboxes, pd_bboxes):
|
||||
"""Calculate IoU for horizontal bounding boxes.
|
||||
|
||||
Args:
|
||||
gt_bboxes (torch.Tensor): Ground truth boxes.
|
||||
pd_bboxes (torch.Tensor): Predicted boxes.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): IoU values between each pair of boxes.
|
||||
"""
|
||||
return bbox_iou(gt_bboxes, pd_bboxes, xywh=False, CIoU=True).squeeze(-1).clamp_(0)
|
||||
|
||||
def select_topk_candidates(self, metrics, topk_mask=None):
|
||||
"""Select the top-k candidates based on the given metrics.
|
||||
|
||||
Args:
|
||||
metrics (torch.Tensor): A tensor of shape (b, max_num_obj, h*w), where b is the batch size, max_num_obj is
|
||||
the maximum number of objects, and h*w represents the total number of anchor points.
|
||||
topk_mask (torch.Tensor, optional): An optional boolean tensor of shape (b, max_num_obj, topk), where topk
|
||||
is the number of top candidates to consider. If not provided, the top-k values are automatically
|
||||
computed based on the given metrics.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): A tensor of shape (b, max_num_obj, h*w) containing the selected top-k candidates.
|
||||
"""
|
||||
# (b, max_num_obj, topk)
|
||||
topk_metrics, topk_idxs = torch.topk(metrics, self.topk, dim=-1, largest=True)
|
||||
if topk_mask is None:
|
||||
topk_mask = (topk_metrics.max(-1, keepdim=True)[0] > self.eps).expand_as(topk_idxs)
|
||||
# (b, max_num_obj, topk)
|
||||
topk_idxs.masked_fill_(~topk_mask, 0)
|
||||
|
||||
# (b, max_num_obj, topk, h*w) -> (b, max_num_obj, h*w)
|
||||
count_tensor = torch.zeros(metrics.shape, dtype=torch.int8, device=topk_idxs.device)
|
||||
ones = torch.ones_like(topk_idxs[:, :, :1], dtype=torch.int8, device=topk_idxs.device)
|
||||
for k in range(self.topk):
|
||||
# Expand topk_idxs for each value of k and add 1 at the specified positions
|
||||
count_tensor.scatter_add_(-1, topk_idxs[:, :, k : k + 1], ones)
|
||||
# Filter invalid bboxes
|
||||
count_tensor.masked_fill_(count_tensor > 1, 0)
|
||||
|
||||
return count_tensor.to(metrics.dtype)
|
||||
|
||||
def get_targets(self, gt_labels, gt_bboxes, target_gt_idx, fg_mask):
|
||||
"""Compute target labels, target bounding boxes, and target scores for the positive anchor points.
|
||||
|
||||
Args:
|
||||
gt_labels (torch.Tensor): Ground truth labels of shape (b, max_num_obj, 1), where b is the batch size and
|
||||
max_num_obj is the maximum number of objects.
|
||||
gt_bboxes (torch.Tensor): Ground truth bounding boxes of shape (b, max_num_obj, 4).
|
||||
target_gt_idx (torch.Tensor): Indices of the assigned ground truth objects for positive anchor points, with
|
||||
shape (b, h*w), where h*w is the total number of anchor points.
|
||||
fg_mask (torch.Tensor): A boolean tensor of shape (b, h*w) indicating the positive (foreground) anchor
|
||||
points.
|
||||
|
||||
Returns:
|
||||
target_labels (torch.Tensor): Target labels for positive anchor points with shape (b, h*w).
|
||||
target_bboxes (torch.Tensor): Target bounding boxes for positive anchor points with shape (b, h*w, 4).
|
||||
target_scores (torch.Tensor): Target scores for positive anchor points with shape (b, h*w, num_classes).
|
||||
"""
|
||||
# Assigned target labels, (b, 1)
|
||||
batch_ind = torch.arange(end=self.bs, dtype=torch.int64, device=gt_labels.device)[..., None]
|
||||
target_gt_idx = target_gt_idx + batch_ind * self.n_max_boxes # (b, h*w)
|
||||
target_labels = gt_labels.long().flatten()[target_gt_idx] # (b, h*w)
|
||||
|
||||
# Assigned target boxes, (b, max_num_obj, 4) -> (b, h*w, 4)
|
||||
target_bboxes = gt_bboxes.view(-1, gt_bboxes.shape[-1])[target_gt_idx]
|
||||
|
||||
# Assigned target scores
|
||||
target_labels.clamp_(0)
|
||||
|
||||
# 10x faster than F.one_hot()
|
||||
target_scores = torch.zeros(
|
||||
(target_labels.shape[0], target_labels.shape[1], self.num_classes),
|
||||
dtype=torch.int64,
|
||||
device=target_labels.device,
|
||||
) # (b, h*w, 80)
|
||||
target_scores.scatter_(2, target_labels.unsqueeze(-1), 1)
|
||||
|
||||
fg_scores_mask = fg_mask[:, :, None].repeat(1, 1, self.num_classes) # (b, h*w, 80)
|
||||
target_scores = torch.where(fg_scores_mask > 0, target_scores, 0)
|
||||
|
||||
return target_labels, target_bboxes, target_scores
|
||||
|
||||
def select_candidates_in_gts(self, xy_centers, gt_bboxes, mask_gt, eps=1e-9):
|
||||
"""Select positive anchor centers within ground truth bounding boxes.
|
||||
|
||||
Args:
|
||||
xy_centers (torch.Tensor): Anchor center coordinates, shape (h*w, 2).
|
||||
gt_bboxes (torch.Tensor): Ground truth bounding boxes, shape (b, n_boxes, 4).
|
||||
mask_gt (torch.Tensor): Mask for valid ground truth boxes, shape (b, n_boxes, 1).
|
||||
eps (float, optional): Small value for numerical stability.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Boolean mask of positive anchors, shape (b, n_boxes, h*w).
|
||||
|
||||
Notes:
|
||||
- b: batch size, n_boxes: number of ground truth boxes, h: height, w: width.
|
||||
- Bounding box format: [x_min, y_min, x_max, y_max].
|
||||
"""
|
||||
gt_bboxes_xywh = xyxy2xywh(gt_bboxes)
|
||||
wh_mask = gt_bboxes_xywh[..., 2:] < self.stride[0] # the smallest stride
|
||||
gt_bboxes_xywh[..., 2:] = torch.where(
|
||||
(wh_mask * mask_gt).bool(),
|
||||
torch.tensor(self.stride_val, dtype=gt_bboxes_xywh.dtype, device=gt_bboxes_xywh.device),
|
||||
gt_bboxes_xywh[..., 2:],
|
||||
)
|
||||
gt_bboxes = xywh2xyxy(gt_bboxes_xywh)
|
||||
|
||||
n_anchors = xy_centers.shape[0]
|
||||
bs, n_boxes, _ = gt_bboxes.shape
|
||||
lt, rb = gt_bboxes.view(-1, 1, 4).chunk(2, 2) # left-top, right-bottom
|
||||
bbox_deltas = torch.cat((xy_centers[None] - lt, rb - xy_centers[None]), dim=2).view(bs, n_boxes, n_anchors, -1)
|
||||
return bbox_deltas.amin(3).gt_(eps)
|
||||
|
||||
def select_highest_overlaps(self, mask_pos, overlaps, n_max_boxes, align_metric):
|
||||
"""Select anchor boxes with highest IoU when assigned to multiple ground truths.
|
||||
|
||||
Args:
|
||||
mask_pos (torch.Tensor): Positive mask, shape (b, n_max_boxes, h*w).
|
||||
overlaps (torch.Tensor): IoU overlaps, shape (b, n_max_boxes, h*w).
|
||||
n_max_boxes (int): Maximum number of ground truth boxes.
|
||||
align_metric (torch.Tensor): Alignment metric for selecting best matches.
|
||||
|
||||
Returns:
|
||||
target_gt_idx (torch.Tensor): Indices of assigned ground truths, shape (b, h*w).
|
||||
fg_mask (torch.Tensor): Foreground mask, shape (b, h*w).
|
||||
mask_pos (torch.Tensor): Updated positive mask, shape (b, n_max_boxes, h*w).
|
||||
"""
|
||||
# Convert (b, n_max_boxes, h*w) -> (b, h*w)
|
||||
fg_mask = mask_pos.sum(-2)
|
||||
if fg_mask.max() > 1: # one anchor is assigned to multiple gt_bboxes
|
||||
mask_multi_gts = (fg_mask.unsqueeze(1) > 1).expand(-1, n_max_boxes, -1) # (b, n_max_boxes, h*w)
|
||||
|
||||
max_overlaps_idx = overlaps.argmax(1) # (b, h*w)
|
||||
is_max_overlaps = torch.zeros(mask_pos.shape, dtype=mask_pos.dtype, device=mask_pos.device)
|
||||
is_max_overlaps.scatter_(1, max_overlaps_idx.unsqueeze(1), 1)
|
||||
mask_pos = torch.where(mask_multi_gts, is_max_overlaps, mask_pos).float() # (b, n_max_boxes, h*w)
|
||||
|
||||
fg_mask = mask_pos.sum(-2)
|
||||
|
||||
if self.topk2 != self.topk:
|
||||
align_metric = align_metric * mask_pos # update overlaps
|
||||
max_overlaps_idx = torch.topk(align_metric, self.topk2, dim=-1, largest=True).indices # (b, n_max_boxes)
|
||||
topk_idx = torch.zeros(mask_pos.shape, dtype=mask_pos.dtype, device=mask_pos.device) # update mask_pos
|
||||
topk_idx.scatter_(-1, max_overlaps_idx, 1.0)
|
||||
mask_pos *= topk_idx
|
||||
fg_mask = mask_pos.sum(-2)
|
||||
# Find each grid serve which gt(index)
|
||||
target_gt_idx = mask_pos.argmax(-2) # (b, h*w)
|
||||
return target_gt_idx, fg_mask, mask_pos
|
||||
|
||||
|
||||
class RotatedTaskAlignedAssigner(TaskAlignedAssigner):
|
||||
"""Assigns ground-truth objects to rotated bounding boxes using a task-aligned metric."""
|
||||
|
||||
def iou_calculation(self, gt_bboxes, pd_bboxes):
|
||||
"""Calculate IoU for rotated bounding boxes."""
|
||||
return probiou(gt_bboxes, pd_bboxes).squeeze(-1).clamp_(0)
|
||||
|
||||
def select_candidates_in_gts(self, xy_centers, gt_bboxes, mask_gt):
|
||||
"""Select the positive anchor center in gt for rotated bounding boxes.
|
||||
|
||||
Args:
|
||||
xy_centers (torch.Tensor): Anchor center coordinates with shape (h*w, 2).
|
||||
gt_bboxes (torch.Tensor): Ground truth bounding boxes with shape (b, n_boxes, 5).
|
||||
mask_gt (torch.Tensor): Mask for valid ground truth boxes with shape (b, n_boxes, 1).
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Boolean mask of positive anchors with shape (b, n_boxes, h*w).
|
||||
"""
|
||||
wh_mask = gt_bboxes[..., 2:4] < self.stride[0]
|
||||
gt_bboxes[..., 2:4] = torch.where(
|
||||
(wh_mask * mask_gt).bool(),
|
||||
torch.tensor(self.stride_val, dtype=gt_bboxes.dtype, device=gt_bboxes.device),
|
||||
gt_bboxes[..., 2:4],
|
||||
)
|
||||
|
||||
# (b, n_boxes, 5) --> (b, n_boxes, 4, 2)
|
||||
corners = xywhr2xyxyxyxy(gt_bboxes)
|
||||
# (b, n_boxes, 1, 2)
|
||||
a, b, _, d = corners.split(1, dim=-2)
|
||||
ab = b - a
|
||||
ad = d - a
|
||||
|
||||
# (b, n_boxes, h*w, 2)
|
||||
ap = xy_centers - a
|
||||
norm_ab = (ab * ab).sum(dim=-1)
|
||||
norm_ad = (ad * ad).sum(dim=-1)
|
||||
ap_dot_ab = (ap * ab).sum(dim=-1)
|
||||
ap_dot_ad = (ap * ad).sum(dim=-1)
|
||||
return (ap_dot_ab >= 0) & (ap_dot_ab <= norm_ab) & (ap_dot_ad >= 0) & (ap_dot_ad <= norm_ad) # is_in_box
|
||||
|
||||
|
||||
def make_anchors(feats, strides, grid_cell_offset=0.5):
|
||||
"""Generate anchors from features."""
|
||||
anchor_points, stride_tensor = [], []
|
||||
assert feats is not None
|
||||
dtype, device = feats[0].dtype, feats[0].device
|
||||
for i in range(len(feats)): # use len(feats) to avoid TracerWarning from iterating over strides tensor
|
||||
stride = strides[i]
|
||||
h, w = feats[i].shape[2:] if isinstance(feats, list) else (int(feats[i][0]), int(feats[i][1]))
|
||||
sx = torch.arange(end=w, device=device, dtype=dtype) + grid_cell_offset # shift x
|
||||
sy = torch.arange(end=h, device=device, dtype=dtype) + grid_cell_offset # shift y
|
||||
sy, sx = torch.meshgrid(sy, sx, indexing="ij") if TORCH_1_11 else torch.meshgrid(sy, sx)
|
||||
anchor_points.append(torch.stack((sx, sy), -1).view(-1, 2))
|
||||
stride_tensor.append(torch.full((h * w, 1), stride, dtype=dtype, device=device))
|
||||
return torch.cat(anchor_points), torch.cat(stride_tensor)
|
||||
|
||||
|
||||
def dist2bbox(distance, anchor_points, xywh=True, dim=-1):
|
||||
"""Transform distance(ltrb) to box(xywh or xyxy)."""
|
||||
lt, rb = distance.chunk(2, dim)
|
||||
x1y1 = anchor_points - lt
|
||||
x2y2 = anchor_points + rb
|
||||
if xywh:
|
||||
c_xy = (x1y1 + x2y2) / 2
|
||||
wh = x2y2 - x1y1
|
||||
return torch.cat([c_xy, wh], dim) # xywh bbox
|
||||
return torch.cat((x1y1, x2y2), dim) # xyxy bbox
|
||||
|
||||
|
||||
def bbox2dist(anchor_points: torch.Tensor, bbox: torch.Tensor, reg_max: int | None = None) -> torch.Tensor:
|
||||
"""Transform bbox(xyxy) to dist(ltrb)."""
|
||||
x1y1, x2y2 = bbox.chunk(2, -1)
|
||||
dist = torch.cat((anchor_points - x1y1, x2y2 - anchor_points), -1)
|
||||
if reg_max is not None:
|
||||
dist = dist.clamp_(0, reg_max - 0.01) # dist (lt, rb)
|
||||
return dist
|
||||
|
||||
|
||||
def dist2rbox(pred_dist, pred_angle, anchor_points, dim=-1):
|
||||
"""Decode predicted rotated bounding box coordinates from anchor points and distribution.
|
||||
|
||||
Args:
|
||||
pred_dist (torch.Tensor): Predicted rotated distance with shape (bs, h*w, 4).
|
||||
pred_angle (torch.Tensor): Predicted angle with shape (bs, h*w, 1).
|
||||
anchor_points (torch.Tensor): Anchor points with shape (h*w, 2).
|
||||
dim (int, optional): Dimension along which to split.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Predicted rotated bounding boxes with shape (bs, h*w, 4).
|
||||
"""
|
||||
lt, rb = pred_dist.split(2, dim=dim)
|
||||
cos, sin = torch.cos(pred_angle), torch.sin(pred_angle)
|
||||
# (bs, h*w, 1)
|
||||
xf, yf = ((rb - lt) / 2).split(1, dim=dim)
|
||||
x, y = xf * cos - yf * sin, xf * sin + yf * cos
|
||||
xy = torch.cat([x, y], dim=dim) + anchor_points
|
||||
return torch.cat([xy, lt + rb], dim=dim)
|
||||
|
||||
|
||||
def rbox2dist(
|
||||
target_bboxes: torch.Tensor,
|
||||
anchor_points: torch.Tensor,
|
||||
target_angle: torch.Tensor,
|
||||
dim: int = -1,
|
||||
reg_max: int | None = None,
|
||||
):
|
||||
"""Transform rotated bounding box (xywh) to distance (ltrb). This is the inverse of dist2rbox.
|
||||
|
||||
Args:
|
||||
target_bboxes (torch.Tensor): Target rotated bounding boxes with shape (bs, h*w, 4), format [x, y, w, h].
|
||||
anchor_points (torch.Tensor): Anchor points with shape (h*w, 2).
|
||||
target_angle (torch.Tensor): Target angle with shape (bs, h*w, 1).
|
||||
dim (int, optional): Dimension along which to split.
|
||||
reg_max (int, optional): Maximum regression value for clamping.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Rotated distance with shape (bs, h*w, 4), format [l, t, r, b].
|
||||
"""
|
||||
xy, wh = target_bboxes.split(2, dim=dim)
|
||||
offset = xy - anchor_points # (bs, h*w, 2)
|
||||
offset_x, offset_y = offset.split(1, dim=dim)
|
||||
cos, sin = torch.cos(target_angle), torch.sin(target_angle)
|
||||
xf = offset_x * cos + offset_y * sin
|
||||
yf = -offset_x * sin + offset_y * cos
|
||||
|
||||
w, h = wh.split(1, dim=dim)
|
||||
target_l = w / 2 - xf
|
||||
target_t = h / 2 - yf
|
||||
target_r = w / 2 + xf
|
||||
target_b = h / 2 + yf
|
||||
|
||||
dist = torch.cat([target_l, target_t, target_r, target_b], dim=dim)
|
||||
if reg_max is not None:
|
||||
dist = dist.clamp_(0, reg_max - 0.01)
|
||||
|
||||
return dist
|
||||
988
ultralytics/utils/torch_utils.py
Executable file
988
ultralytics/utils/torch_utils.py
Executable file
@@ -0,0 +1,988 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import gc
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
from ultralytics import __version__
|
||||
from ultralytics.utils import (
|
||||
DEFAULT_CFG_DICT,
|
||||
DEFAULT_CFG_KEYS,
|
||||
LOGGER,
|
||||
NUM_THREADS,
|
||||
PYTHON_VERSION,
|
||||
TORCH_VERSION,
|
||||
TORCHVISION_VERSION,
|
||||
WINDOWS,
|
||||
colorstr,
|
||||
)
|
||||
from ultralytics.utils.checks import check_version
|
||||
from ultralytics.utils.cpu import CPUInfo
|
||||
from ultralytics.utils.patches import torch_load
|
||||
|
||||
# Version checks (all default to version>=min_version)
|
||||
TORCH_1_9 = check_version(TORCH_VERSION, "1.9.0")
|
||||
TORCH_1_10 = check_version(TORCH_VERSION, "1.10.0")
|
||||
TORCH_1_11 = check_version(TORCH_VERSION, "1.11.0")
|
||||
TORCH_1_13 = check_version(TORCH_VERSION, "1.13.0")
|
||||
TORCH_2_0 = check_version(TORCH_VERSION, "2.0.0")
|
||||
TORCH_2_1 = check_version(TORCH_VERSION, "2.1.0")
|
||||
TORCH_2_3 = check_version(TORCH_VERSION, "2.3.0")
|
||||
TORCH_2_4 = check_version(TORCH_VERSION, "2.4.0")
|
||||
TORCH_2_8 = check_version(TORCH_VERSION, "2.8.0")
|
||||
TORCH_2_9 = check_version(TORCH_VERSION, "2.9.0")
|
||||
TORCH_2_10 = check_version(TORCH_VERSION, "2.10.0")
|
||||
TORCHVISION_0_10 = check_version(TORCHVISION_VERSION, "0.10.0")
|
||||
TORCHVISION_0_11 = check_version(TORCHVISION_VERSION, "0.11.0")
|
||||
TORCHVISION_0_13 = check_version(TORCHVISION_VERSION, "0.13.0")
|
||||
TORCHVISION_0_18 = check_version(TORCHVISION_VERSION, "0.18.0")
|
||||
if WINDOWS and check_version(TORCH_VERSION, "==2.4.0"): # reject version 2.4.0 on Windows
|
||||
LOGGER.warning(
|
||||
"Known issue with torch==2.4.0 on Windows with CPU, recommend upgrading to torch>=2.4.1 to resolve "
|
||||
"https://github.com/ultralytics/ultralytics/issues/15049"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def torch_distributed_zero_first(local_rank: int):
|
||||
"""Ensure all processes in distributed training wait for the local master (rank 0) to complete a task first."""
|
||||
initialized = dist.is_available() and dist.is_initialized()
|
||||
use_ids = initialized and dist.get_backend() == "nccl"
|
||||
|
||||
if initialized and local_rank not in {-1, 0}:
|
||||
dist.barrier(device_ids=[local_rank]) if use_ids else dist.barrier()
|
||||
yield
|
||||
if initialized and local_rank == 0:
|
||||
dist.barrier(device_ids=[local_rank]) if use_ids else dist.barrier()
|
||||
|
||||
|
||||
def smart_inference_mode():
|
||||
"""Apply torch.inference_mode() decorator if torch>=1.10.0, else torch.no_grad() decorator."""
|
||||
|
||||
def decorate(fn):
|
||||
"""Apply appropriate torch decorator for inference mode based on torch version."""
|
||||
if TORCH_1_9 and torch.is_inference_mode_enabled():
|
||||
return fn # already in inference_mode, act as a pass-through
|
||||
else:
|
||||
return (torch.inference_mode if TORCH_1_10 else torch.no_grad)()(fn)
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def autocast(enabled: bool, device: str = "cuda"):
|
||||
"""Get the appropriate autocast context manager based on PyTorch version and AMP setting.
|
||||
|
||||
This function returns a context manager for automatic mixed precision (AMP) training that is compatible with both
|
||||
older and newer versions of PyTorch. It handles the differences in the autocast API between PyTorch versions.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether to enable automatic mixed precision.
|
||||
device (str, optional): The device to use for autocast.
|
||||
|
||||
Returns:
|
||||
(torch.amp.autocast): The appropriate autocast context manager.
|
||||
|
||||
Examples:
|
||||
>>> with autocast(enabled=True):
|
||||
... # Your mixed precision operations here
|
||||
... pass
|
||||
|
||||
Notes:
|
||||
- For PyTorch versions 1.13 and newer, it uses `torch.amp.autocast`.
|
||||
- For older versions, it uses `torch.cuda.amp.autocast`.
|
||||
"""
|
||||
if TORCH_1_13:
|
||||
return torch.amp.autocast(device, enabled=enabled)
|
||||
else:
|
||||
return torch.cuda.amp.autocast(enabled)
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def get_cpu_info():
|
||||
"""Return a string with system CPU information, i.e. 'Apple M2'."""
|
||||
from ultralytics.utils import PERSISTENT_CACHE # avoid circular import error
|
||||
|
||||
if "cpu_info" not in PERSISTENT_CACHE:
|
||||
try:
|
||||
PERSISTENT_CACHE["cpu_info"] = CPUInfo.name()
|
||||
except Exception:
|
||||
pass
|
||||
return PERSISTENT_CACHE.get("cpu_info", "unknown")
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def get_gpu_info(index):
|
||||
"""Return a string with system GPU information, i.e. 'Tesla T4, 15102MiB'."""
|
||||
properties = torch.cuda.get_device_properties(index)
|
||||
return f"{properties.name}, {properties.total_memory / (1 << 20):.0f}MiB"
|
||||
|
||||
|
||||
def select_device(device="", newline=False, verbose=True):
|
||||
"""Select the appropriate PyTorch device based on the provided arguments.
|
||||
|
||||
The function takes a string specifying the device or a torch.device object and returns a torch.device object
|
||||
representing the selected device. The function also validates the number of available devices and raises an
|
||||
exception if the requested device(s) are not available.
|
||||
|
||||
Args:
|
||||
device (str | torch.device, optional): Device string or torch.device object. Options include 'cpu', 'cuda', '0',
|
||||
'0,1,2,3', 'mps', or '-1' for auto-select. Defaults to auto-selecting the first available GPU, or CPU if no
|
||||
GPU is available.
|
||||
newline (bool, optional): If True, adds a newline at the end of the log string.
|
||||
verbose (bool, optional): If True, logs the device information.
|
||||
|
||||
Returns:
|
||||
(torch.device): Selected device.
|
||||
|
||||
Examples:
|
||||
>>> select_device("cuda:0")
|
||||
device(type='cuda', index=0)
|
||||
|
||||
>>> select_device("cpu")
|
||||
device(type='cpu')
|
||||
|
||||
Notes:
|
||||
Sets the 'CUDA_VISIBLE_DEVICES' environment variable for specifying which GPUs to use.
|
||||
"""
|
||||
if isinstance(device, torch.device) or str(device).startswith(("tpu", "intel", "vulkan")):
|
||||
return device
|
||||
|
||||
s = f"Ultralytics {__version__} 🚀 Python-{PYTHON_VERSION} torch-{TORCH_VERSION} "
|
||||
device = str(device).lower()
|
||||
for remove in "cuda:", "none", "(", ")", "[", "]", "'", " ":
|
||||
device = device.replace(remove, "") # to string, 'cuda:0' -> '0' and '(0, 1)' -> '0,1'
|
||||
|
||||
# Auto-select GPUs
|
||||
if "-1" in device:
|
||||
from ultralytics.utils.autodevice import GPUInfo
|
||||
|
||||
# Replace each -1 with a selected GPU or remove it
|
||||
parts = device.split(",")
|
||||
selected = GPUInfo().select_idle_gpu(count=parts.count("-1"), min_memory_fraction=0.2)
|
||||
for i in range(len(parts)):
|
||||
if parts[i] == "-1":
|
||||
parts[i] = str(selected.pop(0)) if selected else ""
|
||||
device = ",".join(p for p in parts if p)
|
||||
|
||||
cpu = device == "cpu"
|
||||
mps = device in {"mps", "mps:0"} # Apple Metal Performance Shaders (MPS)
|
||||
if cpu or mps:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = "" # force torch.cuda.is_available() = False
|
||||
elif device: # non-cpu device requested
|
||||
if device == "cuda":
|
||||
device = "0"
|
||||
if "," in device:
|
||||
device = ",".join([x for x in device.split(",") if x]) # remove sequential commas, i.e. "0,,1" -> "0,1"
|
||||
visible = os.environ.get("CUDA_VISIBLE_DEVICES", None)
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = device # set environment variable - must be before assert is_available()
|
||||
if not (torch.cuda.is_available() and torch.cuda.device_count() >= len(device.split(","))):
|
||||
LOGGER.info(s)
|
||||
install = (
|
||||
"See https://pytorch.org/get-started/locally/ for up-to-date torch install instructions if no "
|
||||
"CUDA devices are seen by torch.\n"
|
||||
if torch.cuda.device_count() == 0
|
||||
else ""
|
||||
)
|
||||
raise ValueError(
|
||||
f"Invalid CUDA 'device={device}' requested."
|
||||
f" Use 'device=cpu' or pass valid CUDA device(s) if available,"
|
||||
f" i.e. 'device=0' or 'device=0,1,2,3' for Multi-GPU.\n"
|
||||
f"\ntorch.cuda.is_available(): {torch.cuda.is_available()}"
|
||||
f"\ntorch.cuda.device_count(): {torch.cuda.device_count()}"
|
||||
f"\nos.environ['CUDA_VISIBLE_DEVICES']: {visible}\n"
|
||||
f"{install}"
|
||||
)
|
||||
|
||||
if not cpu and not mps and torch.cuda.is_available(): # prefer GPU if available
|
||||
devices = device.split(",") if device else "0" # i.e. "0,1" -> ["0", "1"]
|
||||
space = " " * len(s)
|
||||
for i, d in enumerate(devices):
|
||||
s += f"{'' if i == 0 else space}CUDA:{d} ({get_gpu_info(i)})\n" # bytes to MB
|
||||
arg = "cuda:0"
|
||||
elif mps and TORCH_2_0 and torch.backends.mps.is_available():
|
||||
# Prefer MPS if available
|
||||
s += f"MPS ({get_cpu_info()})\n"
|
||||
arg = "mps"
|
||||
else: # revert to CPU
|
||||
s += f"CPU ({get_cpu_info()})\n"
|
||||
arg = "cpu"
|
||||
|
||||
if arg in {"cpu", "mps"}:
|
||||
torch.set_num_threads(NUM_THREADS) # reset OMP_NUM_THREADS for cpu training
|
||||
if verbose:
|
||||
LOGGER.info(s if newline else s.rstrip())
|
||||
return torch.device(arg)
|
||||
|
||||
|
||||
def time_sync():
|
||||
"""Return PyTorch-accurate time."""
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.synchronize()
|
||||
return time.time()
|
||||
|
||||
|
||||
def fuse_conv_and_bn(conv, bn):
|
||||
"""Fuse Conv2d and BatchNorm2d layers for inference optimization.
|
||||
|
||||
Args:
|
||||
conv (nn.Conv2d): Convolutional layer to fuse.
|
||||
bn (nn.BatchNorm2d): Batch normalization layer to fuse.
|
||||
|
||||
Returns:
|
||||
(nn.Conv2d): The fused convolutional layer with gradients disabled.
|
||||
|
||||
Examples:
|
||||
>>> conv = nn.Conv2d(3, 16, 3)
|
||||
>>> bn = nn.BatchNorm2d(16)
|
||||
>>> fused_conv = fuse_conv_and_bn(conv, bn)
|
||||
"""
|
||||
# Compute fused weights
|
||||
w_conv = conv.weight.view(conv.out_channels, -1)
|
||||
w_bn = torch.diag(bn.weight.div(torch.sqrt(bn.eps + bn.running_var)))
|
||||
conv.weight.data = torch.mm(w_bn, w_conv).view(conv.weight.shape)
|
||||
|
||||
# Compute fused bias
|
||||
b_conv = torch.zeros(conv.out_channels, device=conv.weight.device) if conv.bias is None else conv.bias
|
||||
b_bn = bn.bias - bn.weight.mul(bn.running_mean).div(torch.sqrt(bn.running_var + bn.eps))
|
||||
fused_bias = torch.mm(w_bn, b_conv.reshape(-1, 1)).reshape(-1) + b_bn
|
||||
|
||||
if conv.bias is None:
|
||||
conv.register_parameter("bias", nn.Parameter(fused_bias))
|
||||
else:
|
||||
conv.bias.data = fused_bias
|
||||
|
||||
return conv.requires_grad_(False)
|
||||
|
||||
|
||||
def fuse_deconv_and_bn(deconv, bn):
|
||||
"""Fuse ConvTranspose2d and BatchNorm2d layers for inference optimization.
|
||||
|
||||
Args:
|
||||
deconv (nn.ConvTranspose2d): Transposed convolutional layer to fuse.
|
||||
bn (nn.BatchNorm2d): Batch normalization layer to fuse.
|
||||
|
||||
Returns:
|
||||
(nn.ConvTranspose2d): The fused transposed convolutional layer with gradients disabled.
|
||||
|
||||
Examples:
|
||||
>>> deconv = nn.ConvTranspose2d(16, 3, 3)
|
||||
>>> bn = nn.BatchNorm2d(3)
|
||||
>>> fused_deconv = fuse_deconv_and_bn(deconv, bn)
|
||||
"""
|
||||
# Compute fused weights
|
||||
w_deconv = deconv.weight.view(deconv.out_channels, -1)
|
||||
w_bn = torch.diag(bn.weight.div(torch.sqrt(bn.eps + bn.running_var)))
|
||||
deconv.weight.data = torch.mm(w_bn, w_deconv).view(deconv.weight.shape)
|
||||
|
||||
# Compute fused bias
|
||||
b_conv = torch.zeros(deconv.out_channels, device=deconv.weight.device) if deconv.bias is None else deconv.bias
|
||||
b_bn = bn.bias - bn.weight.mul(bn.running_mean).div(torch.sqrt(bn.running_var + bn.eps))
|
||||
fused_bias = torch.mm(w_bn, b_conv.reshape(-1, 1)).reshape(-1) + b_bn
|
||||
|
||||
if deconv.bias is None:
|
||||
deconv.register_parameter("bias", nn.Parameter(fused_bias))
|
||||
else:
|
||||
deconv.bias.data = fused_bias
|
||||
|
||||
return deconv.requires_grad_(False)
|
||||
|
||||
|
||||
def model_info(model, detailed=False, verbose=True, imgsz=640):
|
||||
"""Print and return detailed model information layer by layer.
|
||||
|
||||
Args:
|
||||
model (nn.Module): Model to analyze.
|
||||
detailed (bool, optional): Whether to print detailed layer information.
|
||||
verbose (bool, optional): Whether to print model information.
|
||||
imgsz (int | list, optional): Input image size.
|
||||
|
||||
Returns:
|
||||
(tuple): Tuple containing:
|
||||
- n_l (int): Number of layers.
|
||||
- n_p (int): Number of parameters.
|
||||
- n_g (int): Number of gradients.
|
||||
- flops (float): GFLOPs.
|
||||
"""
|
||||
if not verbose:
|
||||
return
|
||||
n_p = get_num_params(model) # number of parameters
|
||||
n_g = get_num_gradients(model) # number of gradients
|
||||
layers = __import__("collections").OrderedDict((n, m) for n, m in model.named_modules() if len(m._modules) == 0)
|
||||
n_l = len(layers) # number of layers
|
||||
if detailed:
|
||||
h = f"{'layer':>5}{'name':>40}{'type':>20}{'gradient':>10}{'parameters':>12}{'shape':>20}{'mu':>10}{'sigma':>10}"
|
||||
LOGGER.info(h)
|
||||
for i, (mn, m) in enumerate(layers.items()):
|
||||
mn = mn.replace("module_list.", "")
|
||||
mt = m.__class__.__name__
|
||||
if len(m._parameters):
|
||||
for pn, p in m.named_parameters():
|
||||
LOGGER.info(
|
||||
f"{i:>5g}{f'{mn}.{pn}':>40}{mt:>20}{p.requires_grad!r:>10}{p.numel():>12g}{list(p.shape)!s:>20}{p.mean():>10.3g}{p.std():>10.3g}{str(p.dtype).replace('torch.', ''):>15}"
|
||||
)
|
||||
else: # layers with no learnable params
|
||||
LOGGER.info(f"{i:>5g}{mn:>40}{mt:>20}{False!r:>10}{0:>12g}{[]!s:>20}{'-':>10}{'-':>10}{'-':>15}")
|
||||
|
||||
flops = get_flops(model, imgsz) # imgsz may be int or list, i.e. imgsz=640 or imgsz=[640, 320]
|
||||
fused = " (fused)" if getattr(model, "is_fused", lambda: False)() else ""
|
||||
fs = f", {flops:.1f} GFLOPs" if flops else ""
|
||||
yaml_file = getattr(model, "yaml_file", "") or getattr(model, "yaml", {}).get("yaml_file", "")
|
||||
model_name = Path(yaml_file).stem.replace("yolo", "YOLO") or "Model"
|
||||
LOGGER.info(f"{model_name} summary{fused}: {n_l:,} layers, {n_p:,} parameters, {n_g:,} gradients{fs}")
|
||||
return n_l, n_p, n_g, flops
|
||||
|
||||
|
||||
def get_num_params(model):
|
||||
"""Return the total number of parameters in a YOLO model."""
|
||||
return sum(x.numel() for x in model.parameters())
|
||||
|
||||
|
||||
def get_num_gradients(model):
|
||||
"""Return the total number of parameters with gradients in a YOLO model."""
|
||||
return sum(x.numel() for x in model.parameters() if x.requires_grad)
|
||||
|
||||
|
||||
def model_info_for_loggers(trainer):
|
||||
"""Return model info dict with useful model information.
|
||||
|
||||
Args:
|
||||
trainer (ultralytics.engine.trainer.BaseTrainer): The trainer object containing model and validation data.
|
||||
|
||||
Returns:
|
||||
(dict): Dictionary containing model parameters, GFLOPs, and inference speeds.
|
||||
|
||||
Examples:
|
||||
YOLOv8n info for loggers
|
||||
>>> results = {
|
||||
... "model/parameters": 3151904,
|
||||
... "model/GFLOPs": 8.746,
|
||||
... "model/speed_ONNX(ms)": 41.244,
|
||||
... "model/speed_TensorRT(ms)": 3.211,
|
||||
... "model/speed_PyTorch(ms)": 18.755,
|
||||
...}
|
||||
"""
|
||||
if trainer.args.profile: # profile ONNX and TensorRT times
|
||||
from ultralytics.utils.benchmarks import ProfileModels
|
||||
|
||||
results = ProfileModels([trainer.last], device=trainer.device).run()[0]
|
||||
results.pop("model/name")
|
||||
else: # only return PyTorch times from most recent validation
|
||||
results = {
|
||||
"model/parameters": get_num_params(trainer.model),
|
||||
"model/GFLOPs": round(get_flops(trainer.model), 3),
|
||||
}
|
||||
results["model/speed_PyTorch(ms)"] = round(trainer.validator.speed["inference"], 3)
|
||||
return results
|
||||
|
||||
|
||||
def get_flops(model, imgsz=640):
|
||||
"""Calculate FLOPs (floating point operations) for a model in GFLOPs.
|
||||
|
||||
Attempts two calculation methods: first with a stride-based tensor for efficiency, then falls back to full image
|
||||
size if needed (e.g., for RTDETR models). Returns 0.0 if thop library is unavailable or calculation fails.
|
||||
|
||||
Args:
|
||||
model (nn.Module): The model to calculate FLOPs for.
|
||||
imgsz (int | list, optional): Input image size.
|
||||
|
||||
Returns:
|
||||
(float): The model's GFLOPs (billions of floating point operations).
|
||||
"""
|
||||
try:
|
||||
import thop
|
||||
except ImportError:
|
||||
thop = None # conda support without 'ultralytics-thop' installed
|
||||
|
||||
if not thop:
|
||||
return 0.0 # if not installed return 0.0 GFLOPs
|
||||
|
||||
try:
|
||||
model = unwrap_model(model)
|
||||
p = next(model.parameters())
|
||||
if not isinstance(imgsz, list):
|
||||
imgsz = [imgsz, imgsz] # expand if int/float
|
||||
try:
|
||||
# Method 1: Use stride-based input tensor
|
||||
stride = max(int(model.stride.max()), 32) if hasattr(model, "stride") else 32 # max stride
|
||||
im = torch.empty((1, p.shape[1], stride, stride), device=p.device) # input image in BCHW format
|
||||
flops = thop.profile(deepcopy(model), inputs=[im], verbose=False)[0] / 1e9 * 2 # stride GFLOPs
|
||||
return flops * imgsz[0] / stride * imgsz[1] / stride # imgsz GFLOPs
|
||||
except Exception:
|
||||
# Method 2: Use actual image size (required for RTDETR models)
|
||||
im = torch.empty((1, p.shape[1], *imgsz), device=p.device) # input image in BCHW format
|
||||
return thop.profile(deepcopy(model), inputs=[im], verbose=False)[0] / 1e9 * 2 # imgsz GFLOPs
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_flops_with_torch_profiler(model, imgsz=640):
|
||||
"""Compute model FLOPs using torch profiler (alternative to thop package, but 2-10x slower).
|
||||
|
||||
Args:
|
||||
model (nn.Module): The model to calculate FLOPs for.
|
||||
imgsz (int | list, optional): Input image size.
|
||||
|
||||
Returns:
|
||||
(float): The model's GFLOPs (billions of floating point operations).
|
||||
"""
|
||||
if not TORCH_2_0: # torch profiler implemented in torch>=2.0
|
||||
return 0.0
|
||||
model = unwrap_model(model)
|
||||
p = next(model.parameters())
|
||||
if not isinstance(imgsz, list):
|
||||
imgsz = [imgsz, imgsz] # expand if int/float
|
||||
try:
|
||||
# Use stride size for input tensor
|
||||
stride = (max(int(model.stride.max()), 32) if hasattr(model, "stride") else 32) * 2 # max stride
|
||||
im = torch.empty((1, p.shape[1], stride, stride), device=p.device) # input image in BCHW format
|
||||
with torch.profiler.profile(with_flops=True) as prof:
|
||||
model(im)
|
||||
flops = sum(x.flops for x in prof.key_averages()) / 1e9
|
||||
flops = flops * imgsz[0] / stride * imgsz[1] / stride # 640x640 GFLOPs
|
||||
except Exception:
|
||||
# Use actual image size for input tensor (i.e. required for RTDETR models)
|
||||
im = torch.empty((1, p.shape[1], *imgsz), device=p.device) # input image in BCHW format
|
||||
with torch.profiler.profile(with_flops=True) as prof:
|
||||
model(im)
|
||||
flops = sum(x.flops for x in prof.key_averages()) / 1e9
|
||||
return flops
|
||||
|
||||
|
||||
def initialize_weights(model):
|
||||
"""Initialize model weights, biases, and module settings to default values."""
|
||||
for m in model.modules():
|
||||
t = type(m)
|
||||
if t is nn.Conv2d:
|
||||
pass # nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
|
||||
elif t is nn.BatchNorm2d:
|
||||
m.eps = 1e-3
|
||||
m.momentum = 0.03
|
||||
elif t in {nn.Hardswish, nn.LeakyReLU, nn.ReLU, nn.ReLU6, nn.SiLU}:
|
||||
m.inplace = True
|
||||
|
||||
|
||||
def scale_img(img, ratio=1.0, same_shape=False, gs=32):
|
||||
"""Scale and pad an image tensor, optionally maintaining aspect ratio and padding to gs multiple.
|
||||
|
||||
Args:
|
||||
img (torch.Tensor): Input image tensor.
|
||||
ratio (float, optional): Scaling ratio.
|
||||
same_shape (bool, optional): Whether to maintain the same shape.
|
||||
gs (int, optional): Grid size for padding.
|
||||
|
||||
Returns:
|
||||
(torch.Tensor): Scaled and padded image tensor.
|
||||
"""
|
||||
if ratio == 1.0:
|
||||
return img
|
||||
h, w = img.shape[2:]
|
||||
s = (int(h * ratio), int(w * ratio)) # new size
|
||||
img = F.interpolate(img, size=s, mode="bilinear", align_corners=False) # resize
|
||||
if not same_shape: # pad/crop img
|
||||
h, w = (math.ceil(x * ratio / gs) * gs for x in (h, w))
|
||||
return F.pad(img, [0, w - s[1], 0, h - s[0]], value=0.447) # value = imagenet mean
|
||||
|
||||
|
||||
def copy_attr(a, b, include=(), exclude=()):
|
||||
"""Copy attributes from object 'b' to object 'a', with options to include/exclude certain attributes.
|
||||
|
||||
Args:
|
||||
a (Any): Destination object to copy attributes to.
|
||||
b (Any): Source object to copy attributes from.
|
||||
include (tuple, optional): Attributes to include. If empty, all attributes are included.
|
||||
exclude (tuple, optional): Attributes to exclude.
|
||||
"""
|
||||
for k, v in b.__dict__.items():
|
||||
if (len(include) and k not in include) or k.startswith("_") or k in exclude:
|
||||
continue
|
||||
else:
|
||||
setattr(a, k, v)
|
||||
|
||||
|
||||
def intersect_dicts(da, db, exclude=()):
|
||||
"""Return a dictionary of intersecting keys with matching shapes, excluding 'exclude' keys, using da values.
|
||||
|
||||
Args:
|
||||
da (dict): First dictionary.
|
||||
db (dict): Second dictionary.
|
||||
exclude (tuple, optional): Keys to exclude.
|
||||
|
||||
Returns:
|
||||
(dict): Dictionary of intersecting keys with matching shapes.
|
||||
"""
|
||||
return {k: v for k, v in da.items() if k in db and all(x not in k for x in exclude) and v.shape == db[k].shape}
|
||||
|
||||
|
||||
def is_parallel(model):
|
||||
"""Return True if model is of type DP or DDP.
|
||||
|
||||
Args:
|
||||
model (nn.Module): Model to check.
|
||||
|
||||
Returns:
|
||||
(bool): True if model is DataParallel or DistributedDataParallel.
|
||||
"""
|
||||
return isinstance(model, (nn.parallel.DataParallel, nn.parallel.DistributedDataParallel))
|
||||
|
||||
|
||||
def unwrap_model(m: nn.Module) -> nn.Module:
|
||||
"""Unwrap compiled and parallel models to get the base model.
|
||||
|
||||
Args:
|
||||
m (nn.Module): A model that may be wrapped by torch.compile (._orig_mod) or parallel wrappers such as
|
||||
DataParallel/DistributedDataParallel (.module).
|
||||
|
||||
Returns:
|
||||
(nn.Module): The unwrapped base model without compile or parallel wrappers.
|
||||
"""
|
||||
while True:
|
||||
if hasattr(m, "_orig_mod") and isinstance(m._orig_mod, nn.Module):
|
||||
m = m._orig_mod
|
||||
elif hasattr(m, "module") and isinstance(m.module, nn.Module):
|
||||
m = m.module
|
||||
else:
|
||||
return m
|
||||
|
||||
|
||||
def one_cycle(y1=0.0, y2=1.0, steps=100):
|
||||
"""Return a lambda function for sinusoidal ramp from y1 to y2 https://arxiv.org/pdf/1812.01187.pdf.
|
||||
|
||||
Args:
|
||||
y1 (float, optional): Initial value.
|
||||
y2 (float, optional): Final value.
|
||||
steps (int, optional): Number of steps.
|
||||
|
||||
Returns:
|
||||
(function): Lambda function for computing the sinusoidal ramp.
|
||||
"""
|
||||
return lambda x: max((1 - math.cos(x * math.pi / steps)) / 2, 0) * (y2 - y1) + y1
|
||||
|
||||
|
||||
def init_seeds(seed=0, deterministic=False):
|
||||
"""Initialize random number generator (RNG) seeds https://pytorch.org/docs/stable/notes/randomness.html.
|
||||
|
||||
Args:
|
||||
seed (int, optional): Random seed.
|
||||
deterministic (bool, optional): Whether to set deterministic algorithms.
|
||||
"""
|
||||
random.seed(seed)
|
||||
np.random.seed(seed)
|
||||
torch.manual_seed(seed)
|
||||
torch.cuda.manual_seed(seed)
|
||||
torch.cuda.manual_seed_all(seed) # for Multi-GPU, exception safe
|
||||
# torch.backends.cudnn.benchmark = True # AutoBatch problem https://github.com/ultralytics/yolov5/issues/9287
|
||||
if deterministic:
|
||||
if TORCH_2_0:
|
||||
torch.use_deterministic_algorithms(True, warn_only=True) # warn if deterministic is not possible
|
||||
torch.backends.cudnn.deterministic = True
|
||||
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
|
||||
os.environ["PYTHONHASHSEED"] = str(seed)
|
||||
else:
|
||||
LOGGER.warning("Upgrade to torch>=2.0.0 for deterministic training.")
|
||||
else:
|
||||
unset_deterministic()
|
||||
|
||||
|
||||
def unset_deterministic():
|
||||
"""Unset all the configurations applied for deterministic training."""
|
||||
torch.use_deterministic_algorithms(False)
|
||||
torch.backends.cudnn.deterministic = False
|
||||
os.environ.pop("CUBLAS_WORKSPACE_CONFIG", None)
|
||||
os.environ.pop("PYTHONHASHSEED", None)
|
||||
|
||||
|
||||
class ModelEMA:
|
||||
"""Updated Exponential Moving Average (EMA) implementation.
|
||||
|
||||
Keeps a moving average of everything in the model state_dict (parameters and buffers). For EMA details see
|
||||
References.
|
||||
|
||||
To disable EMA set the `enabled` attribute to `False`.
|
||||
|
||||
Attributes:
|
||||
ema (nn.Module): Copy of the model in evaluation mode.
|
||||
updates (int): Number of EMA updates.
|
||||
decay (function): Decay function that determines the EMA weight.
|
||||
enabled (bool): Whether EMA is enabled.
|
||||
|
||||
References:
|
||||
- https://github.com/rwightman/pytorch-image-models
|
||||
- https://www.tensorflow.org/api_docs/python/tf/train/ExponentialMovingAverage
|
||||
"""
|
||||
|
||||
def __init__(self, model, decay=0.9999, tau=2000, updates=0):
|
||||
"""Initialize EMA for 'model' with given arguments.
|
||||
|
||||
Args:
|
||||
model (nn.Module): Model to create EMA for.
|
||||
decay (float, optional): Maximum EMA decay rate.
|
||||
tau (int, optional): EMA decay time constant.
|
||||
updates (int, optional): Initial number of updates.
|
||||
"""
|
||||
self.ema = deepcopy(unwrap_model(model)).eval() # FP32 EMA
|
||||
self.updates = updates # number of EMA updates
|
||||
self.decay = lambda x: decay * (1 - math.exp(-x / tau)) # decay exponential ramp (to help early epochs)
|
||||
for p in self.ema.parameters():
|
||||
p.requires_grad_(False)
|
||||
self.enabled = True
|
||||
|
||||
def update(self, model):
|
||||
"""Update EMA parameters.
|
||||
|
||||
Args:
|
||||
model (nn.Module): Model to update EMA from.
|
||||
"""
|
||||
if self.enabled:
|
||||
self.updates += 1
|
||||
d = self.decay(self.updates)
|
||||
|
||||
msd = unwrap_model(model).state_dict() # model state_dict
|
||||
for k, v in self.ema.state_dict().items():
|
||||
if v.dtype.is_floating_point: # true for FP16 and FP32
|
||||
v *= d
|
||||
v += (1 - d) * msd[k].detach()
|
||||
# assert v.dtype == msd[k].dtype == torch.float32, f'{k}: EMA {v.dtype}, model {msd[k].dtype}'
|
||||
|
||||
def update_attr(self, model, include=(), exclude=("process_group", "reducer")):
|
||||
"""Copy attributes from model to EMA, with options to include/exclude certain attributes.
|
||||
|
||||
Args:
|
||||
model (nn.Module): Model to copy attributes from.
|
||||
include (tuple, optional): Attributes to include.
|
||||
exclude (tuple, optional): Attributes to exclude.
|
||||
"""
|
||||
if self.enabled:
|
||||
copy_attr(self.ema, model, include, exclude)
|
||||
|
||||
|
||||
def strip_optimizer(f: str | Path = "best.pt", s: str = "", updates: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Strip optimizer from 'f' to finalize training, optionally save as 's'.
|
||||
|
||||
Args:
|
||||
f (str | Path): File path to model to strip the optimizer from.
|
||||
s (str, optional): File path to save the model with stripped optimizer to. If not provided, 'f' will be
|
||||
overwritten.
|
||||
updates (dict, optional): A dictionary of updates to overlay onto the checkpoint before saving.
|
||||
|
||||
Returns:
|
||||
(dict): The combined checkpoint dictionary.
|
||||
|
||||
Examples:
|
||||
>>> from pathlib import Path
|
||||
>>> from ultralytics.utils.torch_utils import strip_optimizer
|
||||
>>> for f in Path("path/to/model/checkpoints").rglob("*.pt"):
|
||||
... strip_optimizer(f)
|
||||
"""
|
||||
try:
|
||||
x = torch_load(f, map_location=torch.device("cpu"))
|
||||
assert isinstance(x, dict), "checkpoint is not a Python dictionary"
|
||||
assert "model" in x, "'model' missing from checkpoint"
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Skipping {f}, not a valid Ultralytics model: {e}")
|
||||
return {}
|
||||
|
||||
metadata = {
|
||||
"date": datetime.now().isoformat(),
|
||||
"version": __version__,
|
||||
"license": "AGPL-3.0 License (https://ultralytics.com/license)",
|
||||
"docs": "https://docs.ultralytics.com",
|
||||
}
|
||||
|
||||
# Update model
|
||||
if x.get("ema"):
|
||||
x["model"] = x["ema"] # replace model with EMA
|
||||
if hasattr(x["model"], "args"):
|
||||
x["model"].args = dict(x["model"].args) # convert from IterableSimpleNamespace to dict
|
||||
if hasattr(x["model"], "criterion"):
|
||||
x["model"].criterion = None # strip loss criterion
|
||||
# x["model"].half() # to FP16
|
||||
for p in x["model"].parameters():
|
||||
p.requires_grad = False
|
||||
|
||||
# Update other keys
|
||||
args = {**DEFAULT_CFG_DICT, **x.get("train_args", {})} # combine args
|
||||
for k in "optimizer", "best_fitness", "ema", "updates", "scaler": # keys
|
||||
x[k] = None
|
||||
x["epoch"] = -1
|
||||
x["train_args"] = {k: v for k, v in args.items() if k in DEFAULT_CFG_KEYS} # strip non-default keys
|
||||
# x['model'].args = x['train_args']
|
||||
|
||||
# Save
|
||||
combined = {**metadata, **x, **(updates or {})}
|
||||
torch.save(combined, s or f) # combine dicts (prefer to the right)
|
||||
mb = os.path.getsize(s or f) / 1e6 # file size
|
||||
LOGGER.info(f"Optimizer stripped from {f},{f' saved as {s},' if s else ''} {mb:.1f}MB")
|
||||
return combined
|
||||
|
||||
|
||||
def convert_optimizer_state_dict_to_fp16(state_dict):
|
||||
"""Convert the state_dict of a given optimizer to FP16, focusing on the 'state' key for tensor conversions.
|
||||
|
||||
Args:
|
||||
state_dict (dict): Optimizer state dictionary.
|
||||
|
||||
Returns:
|
||||
(dict): Converted optimizer state dictionary with FP16 tensors.
|
||||
"""
|
||||
for state in state_dict["state"].values():
|
||||
for k, v in state.items():
|
||||
if k != "step" and isinstance(v, torch.Tensor) and v.dtype is torch.float32:
|
||||
state[k] = v.half()
|
||||
|
||||
return state_dict
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cuda_memory_usage(device=None):
|
||||
"""Monitor and manage CUDA memory usage.
|
||||
|
||||
This function checks if CUDA is available and, if so, empties the CUDA cache to free up unused memory. It then
|
||||
yields a dictionary containing memory usage information, which can be updated by the caller. Finally, it updates the
|
||||
dictionary with the amount of memory reserved by CUDA on the specified device.
|
||||
|
||||
Args:
|
||||
device (torch.device, optional): The CUDA device to query memory usage for.
|
||||
|
||||
Yields:
|
||||
(dict): A dictionary with a key 'memory' initialized to 0, which will be updated with the reserved memory.
|
||||
"""
|
||||
cuda_info = dict(memory=0)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
try:
|
||||
yield cuda_info
|
||||
finally:
|
||||
cuda_info["memory"] = torch.cuda.memory_reserved(device)
|
||||
else:
|
||||
yield cuda_info
|
||||
|
||||
|
||||
def profile_ops(input, ops, n=10, device=None, max_num_obj=0):
|
||||
"""Ultralytics speed, memory and FLOPs profiler.
|
||||
|
||||
Args:
|
||||
input (torch.Tensor | list): Input tensor(s) to profile.
|
||||
ops (nn.Module | list): Model or list of operations to profile.
|
||||
n (int, optional): Number of iterations to average.
|
||||
device (str | torch.device, optional): Device to profile on.
|
||||
max_num_obj (int, optional): Maximum number of objects for simulation.
|
||||
|
||||
Returns:
|
||||
(list): Profile results for each operation.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.torch_utils import profile_ops
|
||||
>>> input = torch.randn(16, 3, 640, 640)
|
||||
>>> m1 = lambda x: x * torch.sigmoid(x)
|
||||
>>> m2 = nn.SiLU()
|
||||
>>> profile_ops(input, [m1, m2], n=100) # profile over 100 iterations
|
||||
"""
|
||||
try:
|
||||
import thop
|
||||
except ImportError:
|
||||
thop = None # conda support without 'ultralytics-thop' installed
|
||||
|
||||
results = []
|
||||
if not isinstance(device, torch.device):
|
||||
device = select_device(device)
|
||||
LOGGER.info(
|
||||
f"{'Params':>12s}{'GFLOPs':>12s}{'GPU_mem (GB)':>14s}{'forward (ms)':>14s}{'backward (ms)':>14s}"
|
||||
f"{'input':>24s}{'output':>24s}"
|
||||
)
|
||||
gc.collect() # attempt to free unused memory
|
||||
torch.cuda.empty_cache()
|
||||
for x in input if isinstance(input, list) else [input]:
|
||||
x = x.to(device)
|
||||
x.requires_grad = True
|
||||
for m in ops if isinstance(ops, list) else [ops]:
|
||||
m = m.to(device) if hasattr(m, "to") else m # device
|
||||
m = m.half() if hasattr(m, "half") and isinstance(x, torch.Tensor) and x.dtype is torch.float16 else m
|
||||
tf, tb, t = 0, 0, [0, 0, 0] # dt forward, backward
|
||||
try:
|
||||
flops = thop.profile(deepcopy(m), inputs=[x], verbose=False)[0] / 1e9 * 2 if thop else 0 # GFLOPs
|
||||
except Exception:
|
||||
flops = 0
|
||||
|
||||
try:
|
||||
mem = 0
|
||||
for _ in range(n):
|
||||
with cuda_memory_usage(device) as cuda_info:
|
||||
t[0] = time_sync()
|
||||
y = m(x)
|
||||
t[1] = time_sync()
|
||||
try:
|
||||
(sum(yi.sum() for yi in y) if isinstance(y, list) else y).sum().backward()
|
||||
t[2] = time_sync()
|
||||
except Exception: # no backward method
|
||||
# print(e) # for debug
|
||||
t[2] = float("nan")
|
||||
mem += cuda_info["memory"] / 1e9 # (GB)
|
||||
tf += (t[1] - t[0]) * 1000 / n # ms per op forward
|
||||
tb += (t[2] - t[1]) * 1000 / n # ms per op backward
|
||||
if max_num_obj: # simulate training with predictions per image grid (for AutoBatch)
|
||||
with cuda_memory_usage(device) as cuda_info:
|
||||
torch.randn(
|
||||
x.shape[0],
|
||||
max_num_obj,
|
||||
int(sum((x.shape[-1] / s) * (x.shape[-2] / s) for s in m.stride.tolist())),
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
mem += cuda_info["memory"] / 1e9 # (GB)
|
||||
s_in, s_out = (tuple(x.shape) if isinstance(x, torch.Tensor) else "list" for x in (x, y)) # shapes
|
||||
p = sum(x.numel() for x in m.parameters()) if isinstance(m, nn.Module) else 0 # parameters
|
||||
LOGGER.info(f"{p:12}{flops:12.4g}{mem:>14.3f}{tf:14.4g}{tb:14.4g}{s_in!s:>24s}{s_out!s:>24s}")
|
||||
results.append([p, flops, mem, tf, tb, s_in, s_out])
|
||||
except Exception as e:
|
||||
LOGGER.info(e)
|
||||
results.append(None)
|
||||
finally:
|
||||
gc.collect() # attempt to free unused memory
|
||||
torch.cuda.empty_cache()
|
||||
return results
|
||||
|
||||
|
||||
class EarlyStopping:
|
||||
"""Early stopping class that stops training when a specified number of epochs have passed without improvement.
|
||||
|
||||
Attributes:
|
||||
best_fitness (float): Best fitness value observed.
|
||||
best_epoch (int): Epoch where best fitness was observed.
|
||||
patience (int): Number of epochs to wait after fitness stops improving before stopping.
|
||||
possible_stop (bool): Flag indicating if stopping may occur next epoch.
|
||||
"""
|
||||
|
||||
def __init__(self, patience=50):
|
||||
"""Initialize early stopping object.
|
||||
|
||||
Args:
|
||||
patience (int, optional): Number of epochs to wait after fitness stops improving before stopping.
|
||||
"""
|
||||
self.best_fitness = 0.0 # i.e. mAP
|
||||
self.best_epoch = 0
|
||||
self.patience = patience or float("inf") # epochs to wait after fitness stops improving to stop
|
||||
self.possible_stop = False # possible stop may occur next epoch
|
||||
|
||||
def __call__(self, epoch, fitness):
|
||||
"""Check whether to stop training.
|
||||
|
||||
Args:
|
||||
epoch (int): Current epoch of training.
|
||||
fitness (float): Fitness value of current epoch.
|
||||
|
||||
Returns:
|
||||
(bool): True if training should stop, False otherwise.
|
||||
"""
|
||||
if fitness is None: # check if fitness=None (happens when val=False)
|
||||
return False
|
||||
|
||||
if fitness > self.best_fitness or self.best_fitness == 0: # allow for early zero-fitness stage of training
|
||||
self.best_epoch = epoch
|
||||
self.best_fitness = fitness
|
||||
delta = epoch - self.best_epoch # epochs without improvement
|
||||
self.possible_stop = delta >= (self.patience - 1) # possible stop may occur next epoch
|
||||
stop = delta >= self.patience # stop training if patience exceeded
|
||||
if stop:
|
||||
prefix = colorstr("EarlyStopping: ")
|
||||
LOGGER.info(
|
||||
f"{prefix}Training stopped early as no improvement observed in last {self.patience} epochs. "
|
||||
f"Best results observed at epoch {self.best_epoch}, best model saved as best.pt.\n"
|
||||
f"To update EarlyStopping(patience={self.patience}) pass a new patience value, "
|
||||
f"i.e. `patience=300` or use `patience=0` to disable EarlyStopping."
|
||||
)
|
||||
return stop
|
||||
|
||||
|
||||
def attempt_compile(
|
||||
model: torch.nn.Module,
|
||||
device: torch.device,
|
||||
imgsz: int = 640,
|
||||
use_autocast: bool = False,
|
||||
warmup: bool = False,
|
||||
mode: bool | str = "default",
|
||||
) -> torch.nn.Module:
|
||||
"""Compile a model with torch.compile and optionally warm up the graph to reduce first-iteration latency.
|
||||
|
||||
This utility attempts to compile the provided model using the inductor backend. If compilation is unavailable or
|
||||
fails, the original model is returned unchanged. An optional warmup performs a single forward pass on a dummy input
|
||||
to prime the compiled graph and measure compile/warmup time.
|
||||
|
||||
Args:
|
||||
model (torch.nn.Module): Model to compile.
|
||||
device (torch.device): Inference device used for warmup and autocast decisions.
|
||||
imgsz (int, optional): Square input size to create a dummy tensor with shape (1, 3, imgsz, imgsz) for warmup.
|
||||
use_autocast (bool, optional): Whether to run warmup under autocast on CUDA or MPS devices.
|
||||
warmup (bool, optional): Whether to execute a single dummy forward pass to warm up the compiled model.
|
||||
mode (bool | str, optional): torch.compile mode. True → "default", False → no compile, or a string like
|
||||
"default", "reduce-overhead", "max-autotune-no-cudagraphs".
|
||||
|
||||
Returns:
|
||||
(torch.nn.Module): Compiled model if compilation succeeds, otherwise the original unmodified model.
|
||||
|
||||
Examples:
|
||||
>>> device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
|
||||
>>> # Try to compile and warm up a model with a 640x640 input
|
||||
>>> model = attempt_compile(model, device=device, imgsz=640, use_autocast=True, warmup=True)
|
||||
|
||||
Notes:
|
||||
- If the current PyTorch build does not provide torch.compile, the function returns the input model immediately.
|
||||
- Warmup runs under torch.inference_mode and may use torch.autocast for CUDA/MPS to align compute precision.
|
||||
- CUDA devices are synchronized after warmup to account for asynchronous kernel execution.
|
||||
"""
|
||||
if not hasattr(torch, "compile") or not mode:
|
||||
return model
|
||||
|
||||
if mode is True:
|
||||
mode = "default"
|
||||
prefix = colorstr("compile:")
|
||||
LOGGER.info(f"{prefix} starting torch.compile with '{mode}' mode...")
|
||||
if mode == "max-autotune":
|
||||
LOGGER.warning(f"{prefix} mode='{mode}' not recommended, using mode='max-autotune-no-cudagraphs' instead")
|
||||
mode = "max-autotune-no-cudagraphs"
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
model = torch.compile(model, mode=mode, backend="inductor")
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"{prefix} torch.compile failed, continuing uncompiled: {e}")
|
||||
return model
|
||||
t_compile = time.perf_counter() - t0
|
||||
|
||||
t_warm = 0.0
|
||||
if warmup:
|
||||
# Use a single dummy tensor to build the graph shape state and reduce first-iteration latency
|
||||
dummy = torch.zeros(1, 3, imgsz, imgsz, device=device)
|
||||
if use_autocast and device.type == "cuda":
|
||||
dummy = dummy.half()
|
||||
t1 = time.perf_counter()
|
||||
with torch.inference_mode():
|
||||
if use_autocast and device.type in {"cuda", "mps"}:
|
||||
with torch.autocast(device.type):
|
||||
_ = model(dummy)
|
||||
else:
|
||||
_ = model(dummy)
|
||||
if device.type == "cuda":
|
||||
torch.cuda.synchronize(device)
|
||||
t_warm = time.perf_counter() - t1
|
||||
|
||||
total = t_compile + t_warm
|
||||
if warmup:
|
||||
LOGGER.info(f"{prefix} complete in {total:.1f}s (compile {t_compile:.1f}s + warmup {t_warm:.1f}s)")
|
||||
else:
|
||||
LOGGER.info(f"{prefix} compile complete in {t_compile:.1f}s (no warmup)")
|
||||
return model
|
||||
443
ultralytics/utils/tqdm.py
Executable file
443
ultralytics/utils/tqdm.py
Executable file
@@ -0,0 +1,443 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import IO, Any
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def is_noninteractive_console() -> bool:
|
||||
"""Check for known non-interactive console environments."""
|
||||
return "GITHUB_ACTIONS" in os.environ or "RUNPOD_POD_ID" in os.environ
|
||||
|
||||
|
||||
class TQDM:
|
||||
"""Lightweight zero-dependency progress bar for Ultralytics.
|
||||
|
||||
Provides clean, rich-style progress bars suitable for various environments including Weights & Biases, console
|
||||
outputs, and other logging systems. Features zero external dependencies, clean single-line output, rich-style
|
||||
progress bars with Unicode block characters, context manager support, iterator protocol support, and dynamic
|
||||
description updates.
|
||||
|
||||
Attributes:
|
||||
iterable (Any): Iterable to wrap with progress bar.
|
||||
desc (str): Prefix description for the progress bar.
|
||||
total (int | None): Expected number of iterations.
|
||||
disable (bool): Whether to disable the progress bar.
|
||||
unit (str): String for units of iteration.
|
||||
unit_scale (bool): Auto-scale units flag.
|
||||
unit_divisor (int): Divisor for unit scaling.
|
||||
leave (bool): Whether to leave the progress bar after completion.
|
||||
mininterval (float): Minimum time interval between updates.
|
||||
initial (int): Initial counter value.
|
||||
n (int): Current iteration count.
|
||||
closed (bool): Whether the progress bar is closed.
|
||||
bar_format (str | None): Custom bar format string.
|
||||
file (IO[str]): Output file stream.
|
||||
|
||||
Methods:
|
||||
update: Update progress by n steps.
|
||||
set_description: Set or update the description.
|
||||
set_postfix: Set postfix for the progress bar.
|
||||
close: Close the progress bar and clean up.
|
||||
refresh: Refresh the progress bar display.
|
||||
clear: Clear the progress bar from display.
|
||||
write: Write a message without breaking the progress bar.
|
||||
|
||||
Examples:
|
||||
Basic usage with iterator:
|
||||
>>> for i in TQDM(range(100)):
|
||||
... time.sleep(0.01)
|
||||
|
||||
With custom description:
|
||||
>>> pbar = TQDM(range(100), desc="Processing")
|
||||
>>> for i in pbar:
|
||||
... pbar.set_description(f"Processing item {i}")
|
||||
|
||||
Context manager usage:
|
||||
>>> with TQDM(total=100, unit="B", unit_scale=True) as pbar:
|
||||
... for i in range(100):
|
||||
... pbar.update(1)
|
||||
|
||||
Manual updates:
|
||||
>>> pbar = TQDM(total=100, desc="Training")
|
||||
>>> for epoch in range(100):
|
||||
... # Do work
|
||||
... pbar.update(1)
|
||||
>>> pbar.close()
|
||||
"""
|
||||
|
||||
# Constants
|
||||
MIN_RATE_CALC_INTERVAL = 0.01 # Minimum time interval for rate calculation
|
||||
RATE_SMOOTHING_FACTOR = 0.3 # Factor for exponential smoothing of rates
|
||||
MAX_SMOOTHED_RATE = 1000000 # Maximum rate to apply smoothing to
|
||||
NONINTERACTIVE_MIN_INTERVAL = 60.0 # Minimum interval for non-interactive environments
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
iterable: Any = None,
|
||||
desc: str | None = None,
|
||||
total: int | None = None,
|
||||
leave: bool = True,
|
||||
file: IO[str] | None = None,
|
||||
mininterval: float = 0.1,
|
||||
disable: bool | None = None,
|
||||
unit: str = "it",
|
||||
unit_scale: bool = True,
|
||||
unit_divisor: int = 1000,
|
||||
bar_format: str | None = None, # kept for API compatibility; not used for formatting
|
||||
initial: int = 0,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Initialize the TQDM progress bar with specified configuration options.
|
||||
|
||||
Args:
|
||||
iterable (Any, optional): Iterable to wrap with progress bar.
|
||||
desc (str, optional): Prefix description for the progress bar.
|
||||
total (int, optional): Expected number of iterations.
|
||||
leave (bool, optional): Whether to leave the progress bar after completion.
|
||||
file (IO[str], optional): Output file stream for progress display.
|
||||
mininterval (float, optional): Minimum time interval between updates (default 0.1s, 60s in GitHub Actions).
|
||||
disable (bool, optional): Whether to disable the progress bar. Auto-detected if None.
|
||||
unit (str, optional): String for units of iteration (default "it" for items).
|
||||
unit_scale (bool, optional): Auto-scale units for bytes/data units.
|
||||
unit_divisor (int, optional): Divisor for unit scaling (default 1000).
|
||||
bar_format (str, optional): Custom bar format string.
|
||||
initial (int, optional): Initial counter value.
|
||||
**kwargs (Any): Additional keyword arguments for compatibility (ignored).
|
||||
"""
|
||||
# Disable if not verbose
|
||||
if disable is None:
|
||||
try:
|
||||
from ultralytics.utils import LOGGER, VERBOSE
|
||||
|
||||
disable = not VERBOSE or LOGGER.getEffectiveLevel() > 20
|
||||
except ImportError:
|
||||
disable = False
|
||||
|
||||
self.iterable = iterable
|
||||
self.desc = desc or ""
|
||||
self.total = total or (len(iterable) if hasattr(iterable, "__len__") else None) or None # prevent total=0
|
||||
self.disable = disable
|
||||
self.unit = unit
|
||||
self.unit_scale = unit_scale
|
||||
self.unit_divisor = unit_divisor
|
||||
self.leave = leave
|
||||
self.noninteractive = is_noninteractive_console()
|
||||
self.mininterval = max(mininterval, self.NONINTERACTIVE_MIN_INTERVAL) if self.noninteractive else mininterval
|
||||
self.initial = initial
|
||||
|
||||
# Kept for API compatibility (unused for f-string formatting)
|
||||
self.bar_format = bar_format
|
||||
|
||||
self.file = file or sys.stdout
|
||||
|
||||
# Internal state
|
||||
self.n = self.initial
|
||||
self.last_print_n = self.initial
|
||||
self.last_print_t = time.time()
|
||||
self.start_t = time.time()
|
||||
self.last_rate = 0.0
|
||||
self.closed = False
|
||||
self.is_bytes = unit_scale and unit in {"B", "bytes"}
|
||||
self.scales = (
|
||||
[(1073741824, "GB/s"), (1048576, "MB/s"), (1024, "KB/s")]
|
||||
if self.is_bytes
|
||||
else [(1e9, f"G{self.unit}/s"), (1e6, f"M{self.unit}/s"), (1e3, f"K{self.unit}/s")]
|
||||
)
|
||||
|
||||
if not self.disable and self.total and not self.noninteractive:
|
||||
self._display()
|
||||
|
||||
def _format_rate(self, rate: float) -> str:
|
||||
"""Format rate with units, switching between it/s and s/it for readability."""
|
||||
if rate <= 0:
|
||||
return ""
|
||||
|
||||
inv_rate = 1 / rate if rate else None
|
||||
|
||||
# Use s/it format when inv_rate > 1 (i.e., rate < 1 it/s) for better readability
|
||||
if inv_rate and inv_rate > 1:
|
||||
return f"{inv_rate:.1f}s/B" if self.is_bytes else f"{inv_rate:.1f}s/{self.unit}"
|
||||
|
||||
# Use it/s format for fast iterations
|
||||
fallback = f"{rate:.1f}B/s" if self.is_bytes else f"{rate:.1f}{self.unit}/s"
|
||||
return next((f"{rate / t:.1f}{u}" for t, u in self.scales if rate >= t), fallback)
|
||||
|
||||
def _format_num(self, num: int | float) -> str:
|
||||
"""Format number with optional unit scaling."""
|
||||
if not self.unit_scale or not self.is_bytes:
|
||||
return str(num)
|
||||
|
||||
for unit in ("", "K", "M", "G", "T"):
|
||||
if abs(num) < self.unit_divisor:
|
||||
return f"{num:3.1f}{unit}B" if unit else f"{num:.0f}B"
|
||||
num /= self.unit_divisor
|
||||
return f"{num:.1f}PB"
|
||||
|
||||
@staticmethod
|
||||
def _format_time(seconds: float) -> str:
|
||||
"""Format time duration."""
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
elif seconds < 3600:
|
||||
return f"{int(seconds // 60)}:{seconds % 60:02.0f}"
|
||||
else:
|
||||
h, m = int(seconds // 3600), int((seconds % 3600) // 60)
|
||||
return f"{h}:{m:02d}:{seconds % 60:02.0f}"
|
||||
|
||||
def _generate_bar(self, width: int = 12) -> str:
|
||||
"""Generate progress bar."""
|
||||
if self.total is None:
|
||||
return "━" * width if self.closed else "─" * width
|
||||
|
||||
frac = min(1.0, self.n / self.total)
|
||||
filled = int(frac * width)
|
||||
bar = "━" * filled + "─" * (width - filled)
|
||||
if filled < width and frac * width - filled > 0.5:
|
||||
bar = f"{bar[:filled]}╸{bar[filled + 1 :]}"
|
||||
return bar
|
||||
|
||||
def _should_update(self, dt: float, dn: int) -> bool:
|
||||
"""Check if display should update."""
|
||||
if self.noninteractive:
|
||||
return False
|
||||
return (self.total is not None and self.n >= self.total) or (dt >= self.mininterval)
|
||||
|
||||
def _display(self, final: bool = False) -> None:
|
||||
"""Display progress bar."""
|
||||
if self.disable or (self.closed and not final):
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
dt = current_time - self.last_print_t
|
||||
dn = self.n - self.last_print_n
|
||||
|
||||
if not final and not self._should_update(dt, dn):
|
||||
return
|
||||
|
||||
# Calculate rate (avoid crazy numbers)
|
||||
if dt > self.MIN_RATE_CALC_INTERVAL:
|
||||
rate = dn / dt if dt else 0.0
|
||||
# Smooth rate for reasonable values, use raw rate for very high values
|
||||
if rate < self.MAX_SMOOTHED_RATE:
|
||||
self.last_rate = self.RATE_SMOOTHING_FACTOR * rate + (1 - self.RATE_SMOOTHING_FACTOR) * self.last_rate
|
||||
rate = self.last_rate
|
||||
else:
|
||||
rate = self.last_rate
|
||||
|
||||
# At completion, use overall rate
|
||||
if self.total and self.n >= self.total:
|
||||
overall_elapsed = current_time - self.start_t
|
||||
if overall_elapsed > 0:
|
||||
rate = self.n / overall_elapsed
|
||||
|
||||
# Update counters
|
||||
self.last_print_n = self.n
|
||||
self.last_print_t = current_time
|
||||
elapsed = current_time - self.start_t
|
||||
|
||||
# Remaining time
|
||||
remaining_str = ""
|
||||
if self.total and 0 < self.n < self.total and elapsed > 0:
|
||||
est_rate = rate or (self.n / elapsed)
|
||||
remaining_str = f"<{self._format_time((self.total - self.n) / est_rate)}"
|
||||
|
||||
# Numbers and percent
|
||||
if self.total:
|
||||
percent = (self.n / self.total) * 100
|
||||
n_str = self._format_num(self.n)
|
||||
t_str = self._format_num(self.total)
|
||||
if self.is_bytes and n_str[-2] == t_str[-2]: # Collapse suffix only when identical (e.g. "5.4/5.4MB")
|
||||
n_str = n_str.rstrip("KMGTPB")
|
||||
else:
|
||||
percent = 0.0
|
||||
n_str, t_str = self._format_num(self.n), "?"
|
||||
|
||||
elapsed_str = self._format_time(elapsed)
|
||||
rate_str = self._format_rate(rate) or (self._format_rate(self.n / elapsed) if elapsed > 0 else "")
|
||||
|
||||
bar = self._generate_bar()
|
||||
|
||||
# Compose progress line via f-strings (two shapes: with/without total)
|
||||
if self.total:
|
||||
if self.is_bytes and self.n >= self.total:
|
||||
# Completed bytes: show only final size
|
||||
progress_str = f"{self.desc}: {percent:.0f}% {bar} {t_str} {rate_str} {elapsed_str}"
|
||||
else:
|
||||
progress_str = (
|
||||
f"{self.desc}: {percent:.0f}% {bar} {n_str}/{t_str} {rate_str} {elapsed_str}{remaining_str}"
|
||||
)
|
||||
else:
|
||||
progress_str = f"{self.desc}: {bar} {n_str} {rate_str} {elapsed_str}"
|
||||
|
||||
# Write to output
|
||||
try:
|
||||
if self.noninteractive:
|
||||
# In non-interactive environments, avoid carriage return which creates empty lines
|
||||
self.file.write(progress_str)
|
||||
else:
|
||||
# In interactive terminals, use carriage return and clear line for updating display
|
||||
self.file.write(f"\r\033[K{progress_str}")
|
||||
self.file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update(self, n: int = 1) -> None:
|
||||
"""Update progress by n steps."""
|
||||
if not self.disable and not self.closed:
|
||||
self.n += n
|
||||
self._display()
|
||||
|
||||
def set_description(self, desc: str | None) -> None:
|
||||
"""Set description."""
|
||||
self.desc = desc or ""
|
||||
if not self.disable:
|
||||
self._display()
|
||||
|
||||
def set_postfix(self, **kwargs: Any) -> None:
|
||||
"""Set postfix (appends to description)."""
|
||||
if kwargs:
|
||||
postfix = ", ".join(f"{k}={v}" for k, v in kwargs.items())
|
||||
base_desc = self.desc.split(" | ")[0] if " | " in self.desc else self.desc
|
||||
self.set_description(f"{base_desc} | {postfix}")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close progress bar."""
|
||||
if self.closed:
|
||||
return
|
||||
|
||||
self.closed = True
|
||||
|
||||
if not self.disable:
|
||||
# Final display
|
||||
if self.total and self.n >= self.total:
|
||||
self.n = self.total
|
||||
if self.n != self.last_print_n: # Skip if 100% already shown
|
||||
self._display(final=True)
|
||||
else:
|
||||
self._display(final=True)
|
||||
|
||||
# Cleanup
|
||||
if self.leave:
|
||||
self.file.write("\n")
|
||||
else:
|
||||
self.file.write("\r\033[K")
|
||||
|
||||
try:
|
||||
self.file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> TQDM:
|
||||
"""Enter context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
"""Exit context manager and close progress bar."""
|
||||
self.close()
|
||||
|
||||
def __iter__(self) -> Any:
|
||||
"""Iterate over the wrapped iterable with progress updates."""
|
||||
if self.iterable is None:
|
||||
raise TypeError("'NoneType' object is not iterable")
|
||||
|
||||
try:
|
||||
for item in self.iterable:
|
||||
yield item
|
||||
self.update(1)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Destructor to ensure cleanup."""
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh display."""
|
||||
if not self.disable:
|
||||
self._display()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear progress bar."""
|
||||
if not self.disable:
|
||||
try:
|
||||
self.file.write("\r\033[K")
|
||||
self.file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def write(s: str, file: IO[str] | None = None, end: str = "\n") -> None:
|
||||
"""Static method to write without breaking progress bar."""
|
||||
file = file or sys.stdout
|
||||
try:
|
||||
file.write(s + end)
|
||||
file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
|
||||
print("1. Basic progress bar with known total:")
|
||||
for i in TQDM(range(3), desc="Known total"):
|
||||
time.sleep(0.05)
|
||||
|
||||
print("\n2. Manual updates with known total:")
|
||||
pbar = TQDM(total=300, desc="Manual updates", unit="files")
|
||||
for i in range(300):
|
||||
time.sleep(0.03)
|
||||
pbar.update(1)
|
||||
if i % 10 == 9:
|
||||
pbar.set_description(f"Processing batch {i // 10 + 1}")
|
||||
pbar.close()
|
||||
|
||||
print("\n3. Progress bar with unknown total:")
|
||||
pbar = TQDM(desc="Unknown total", unit="items")
|
||||
for i in range(25):
|
||||
time.sleep(0.08)
|
||||
pbar.update(1)
|
||||
if i % 5 == 4:
|
||||
pbar.set_postfix(processed=i + 1, status="OK")
|
||||
pbar.close()
|
||||
|
||||
print("\n4. Context manager with unknown total:")
|
||||
with TQDM(desc="Processing stream", unit="B", unit_scale=True, unit_divisor=1024) as pbar:
|
||||
for i in range(30):
|
||||
time.sleep(0.1)
|
||||
pbar.update(1024 * 1024 * i) # Simulate processing MB of data
|
||||
|
||||
print("\n5. Iterator with unknown length:")
|
||||
|
||||
def data_stream():
|
||||
"""Simulate a data stream of unknown length."""
|
||||
import random
|
||||
|
||||
for i in range(random.randint(10, 20)):
|
||||
yield f"data_chunk_{i}"
|
||||
|
||||
for chunk in TQDM(data_stream(), desc="Stream processing", unit="chunks"):
|
||||
time.sleep(0.1)
|
||||
|
||||
print("\n6. File processing simulation (unknown size):")
|
||||
|
||||
def process_files():
|
||||
"""Simulate processing files of unknown count."""
|
||||
return [f"file_{i}.txt" for i in range(18)]
|
||||
|
||||
pbar = TQDM(desc="Scanning files", unit="files")
|
||||
files = process_files()
|
||||
for i, filename in enumerate(files):
|
||||
time.sleep(0.06)
|
||||
pbar.update(1)
|
||||
pbar.set_description(f"Processing {filename}")
|
||||
pbar.close()
|
||||
112
ultralytics/utils/triton.py
Executable file
112
ultralytics/utils/triton.py
Executable file
@@ -0,0 +1,112 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TritonRemoteModel:
|
||||
"""Client for interacting with a remote Triton Inference Server model.
|
||||
|
||||
This class provides a convenient interface for sending inference requests to a Triton Inference Server and
|
||||
processing the responses. Supports both HTTP and gRPC communication protocols.
|
||||
|
||||
Attributes:
|
||||
endpoint (str): The name of the model on the Triton server.
|
||||
url (str): The URL of the Triton server.
|
||||
triton_client: The Triton client (either HTTP or gRPC).
|
||||
InferInput: The input class for the Triton client.
|
||||
InferRequestedOutput: The output request class for the Triton client.
|
||||
input_formats (list[str]): The data types of the model inputs.
|
||||
np_input_formats (list[type]): The numpy data types of the model inputs.
|
||||
input_names (list[str]): The names of the model inputs.
|
||||
output_names (list[str]): The names of the model outputs.
|
||||
metadata: The metadata associated with the model.
|
||||
|
||||
Methods:
|
||||
__call__: Call the model with the given inputs and return the outputs.
|
||||
|
||||
Examples:
|
||||
Initialize a Triton client with HTTP
|
||||
>>> model = TritonRemoteModel(url="localhost:8000", endpoint="yolov8", scheme="http")
|
||||
|
||||
Make inference with numpy arrays
|
||||
>>> outputs = model(np.random.rand(1, 3, 640, 640).astype(np.float32))
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, endpoint: str = "", scheme: str = ""):
|
||||
"""Initialize the TritonRemoteModel for interacting with a remote Triton Inference Server.
|
||||
|
||||
Arguments may be provided individually or parsed from a collective 'url' argument of the form
|
||||
<scheme>://<netloc>/<endpoint>/<task_name>
|
||||
|
||||
Args:
|
||||
url (str): The URL of the Triton server.
|
||||
endpoint (str, optional): The name of the model on the Triton server.
|
||||
scheme (str, optional): The communication scheme ('http' or 'grpc').
|
||||
"""
|
||||
if not endpoint and not scheme: # Parse all args from URL string
|
||||
splits = urlsplit(url)
|
||||
endpoint = splits.path.strip("/").split("/", 1)[0]
|
||||
scheme = splits.scheme
|
||||
url = splits.netloc
|
||||
|
||||
self.endpoint = endpoint
|
||||
self.url = url
|
||||
|
||||
# Choose the Triton client based on the communication scheme
|
||||
if scheme == "http":
|
||||
import tritonclient.http as client
|
||||
|
||||
self.triton_client = client.InferenceServerClient(url=self.url, verbose=False, ssl=False)
|
||||
config = self.triton_client.get_model_config(endpoint)
|
||||
else:
|
||||
import tritonclient.grpc as client
|
||||
|
||||
self.triton_client = client.InferenceServerClient(url=self.url, verbose=False, ssl=False)
|
||||
config = self.triton_client.get_model_config(endpoint, as_json=True)["config"]
|
||||
|
||||
# Sort output names alphabetically, i.e. 'output0', 'output1', etc.
|
||||
config["output"] = sorted(config["output"], key=lambda x: x.get("name"))
|
||||
|
||||
# Define model attributes
|
||||
type_map = {"TYPE_FP32": np.float32, "TYPE_FP16": np.float16, "TYPE_UINT8": np.uint8}
|
||||
self.InferRequestedOutput = client.InferRequestedOutput
|
||||
self.InferInput = client.InferInput
|
||||
self.input_formats = [x["data_type"] for x in config["input"]]
|
||||
self.np_input_formats = [type_map[x] for x in self.input_formats]
|
||||
self.input_names = [x["name"] for x in config["input"]]
|
||||
self.output_names = [x["name"] for x in config["output"]]
|
||||
self.metadata = ast.literal_eval(config.get("parameters", {}).get("metadata", {}).get("string_value", "None"))
|
||||
|
||||
def __call__(self, *inputs: np.ndarray) -> list[np.ndarray]:
|
||||
"""Call the model with the given inputs and return inference results.
|
||||
|
||||
Args:
|
||||
*inputs (np.ndarray): Input data to the model. Each array should match the expected shape and type for the
|
||||
corresponding model input.
|
||||
|
||||
Returns:
|
||||
(list[np.ndarray]): Model outputs cast to the dtype of the first input. Each element in the list corresponds
|
||||
to one of the model's output tensors.
|
||||
|
||||
Examples:
|
||||
>>> model = TritonRemoteModel(url="localhost:8000", endpoint="yolov8", scheme="http")
|
||||
>>> outputs = model(np.random.rand(1, 3, 640, 640).astype(np.float32))
|
||||
"""
|
||||
infer_inputs = []
|
||||
input_format = inputs[0].dtype
|
||||
for i, x in enumerate(inputs):
|
||||
if x.dtype != self.np_input_formats[i]:
|
||||
x = x.astype(self.np_input_formats[i])
|
||||
infer_input = self.InferInput(self.input_names[i], [*x.shape], self.input_formats[i].replace("TYPE_", ""))
|
||||
infer_input.set_data_from_numpy(x)
|
||||
infer_inputs.append(infer_input)
|
||||
|
||||
infer_outputs = [self.InferRequestedOutput(output_name) for output_name in self.output_names]
|
||||
outputs = self.triton_client.infer(model_name=self.endpoint, inputs=infer_inputs, outputs=infer_outputs)
|
||||
|
||||
return [outputs.as_numpy(output_name).astype(input_format) for output_name in self.output_names]
|
||||
168
ultralytics/utils/tuner.py
Executable file
168
ultralytics/utils/tuner.py
Executable file
@@ -0,0 +1,168 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ultralytics.cfg import TASK2DATA, TASK2METRIC, get_cfg, get_save_dir
|
||||
from ultralytics.utils import DEFAULT_CFG, DEFAULT_CFG_DICT, LOGGER, NUM_THREADS, checks, colorstr
|
||||
|
||||
|
||||
def run_ray_tune(
|
||||
model,
|
||||
space: dict | None = None,
|
||||
grace_period: int = 10,
|
||||
gpu_per_trial: int | None = None,
|
||||
max_samples: int = 10,
|
||||
**train_args,
|
||||
):
|
||||
"""Run hyperparameter tuning using Ray Tune.
|
||||
|
||||
Args:
|
||||
model (YOLO): Model to run the tuner on.
|
||||
space (dict, optional): The hyperparameter search space. If not provided, uses default space.
|
||||
grace_period (int, optional): The grace period in epochs of the ASHA scheduler.
|
||||
gpu_per_trial (int, optional): The number of GPUs to allocate per trial.
|
||||
max_samples (int, optional): The maximum number of trials to run.
|
||||
**train_args (Any): Additional arguments to pass to the `train()` method.
|
||||
|
||||
Returns:
|
||||
(ray.tune.ResultGrid): A ResultGrid containing the results of the hyperparameter search.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics import YOLO
|
||||
>>> model = YOLO("yolo26n.pt") # Load a YOLO26n model
|
||||
|
||||
Start tuning hyperparameters for YOLO26n training on the COCO8 dataset
|
||||
>>> result_grid = model.tune(data="coco8.yaml", use_ray=True)
|
||||
"""
|
||||
LOGGER.info("💡 Learn about RayTune at https://docs.ultralytics.com/integrations/ray-tune")
|
||||
try:
|
||||
checks.check_requirements("ray[tune]")
|
||||
|
||||
import ray
|
||||
from ray import tune
|
||||
from ray.air import RunConfig
|
||||
from ray.air.integrations.wandb import WandbLoggerCallback
|
||||
from ray.tune.schedulers import ASHAScheduler
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError('Ray Tune required but not found. To install run: pip install "ray[tune]"')
|
||||
|
||||
try:
|
||||
import wandb
|
||||
|
||||
assert hasattr(wandb, "__version__")
|
||||
except (ImportError, AssertionError):
|
||||
wandb = False
|
||||
|
||||
checks.check_version(ray.__version__, ">=2.0.0", "ray")
|
||||
default_space = {
|
||||
# 'optimizer': tune.choice(['SGD', 'Adam', 'AdamW', 'NAdam', 'RAdam', 'RMSProp']),
|
||||
"lr0": tune.uniform(1e-5, 1e-1),
|
||||
"lrf": tune.uniform(0.01, 1.0), # final OneCycleLR learning rate (lr0 * lrf)
|
||||
"momentum": tune.uniform(0.6, 0.98), # SGD momentum/Adam beta1
|
||||
"weight_decay": tune.uniform(0.0, 0.001), # optimizer weight decay
|
||||
"warmup_epochs": tune.uniform(0.0, 5.0), # warmup epochs (fractions ok)
|
||||
"warmup_momentum": tune.uniform(0.0, 0.95), # warmup initial momentum
|
||||
"box": tune.uniform(0.02, 0.2), # box loss gain
|
||||
"cls": tune.uniform(0.2, 4.0), # cls loss gain (scale with pixels)
|
||||
"hsv_h": tune.uniform(0.0, 0.1), # image HSV-Hue augmentation (fraction)
|
||||
"hsv_s": tune.uniform(0.0, 0.9), # image HSV-Saturation augmentation (fraction)
|
||||
"hsv_v": tune.uniform(0.0, 0.9), # image HSV-Value augmentation (fraction)
|
||||
"degrees": tune.uniform(0.0, 45.0), # image rotation (+/- deg)
|
||||
"translate": tune.uniform(0.0, 0.9), # image translation (+/- fraction)
|
||||
"scale": tune.uniform(0.0, 0.9), # image scale (+/- gain)
|
||||
"shear": tune.uniform(0.0, 10.0), # image shear (+/- deg)
|
||||
"perspective": tune.uniform(0.0, 0.001), # image perspective (+/- fraction), range 0-0.001
|
||||
"flipud": tune.uniform(0.0, 1.0), # image flip up-down (probability)
|
||||
"fliplr": tune.uniform(0.0, 1.0), # image flip left-right (probability)
|
||||
"bgr": tune.uniform(0.0, 1.0), # swap RGB↔BGR channels (probability)
|
||||
"mosaic": tune.uniform(0.0, 1.0), # image mosaic (probability)
|
||||
"mixup": tune.uniform(0.0, 1.0), # image mixup (probability)
|
||||
"cutmix": tune.uniform(0.0, 1.0), # image cutmix (probability)
|
||||
"copy_paste": tune.uniform(0.0, 1.0), # segment copy-paste (probability)
|
||||
}
|
||||
|
||||
# Put the model in ray store
|
||||
task = model.task
|
||||
model_in_store = ray.put(model)
|
||||
base_name = train_args.get("name", "tune")
|
||||
|
||||
def _tune(config):
|
||||
"""Train the YOLO model with the specified hyperparameters and return results."""
|
||||
model_to_train = ray.get(model_in_store) # get the model from ray store for tuning
|
||||
model_to_train.reset_callbacks()
|
||||
config.update(train_args)
|
||||
|
||||
# Set trial-specific name for W&B logging
|
||||
try:
|
||||
trial_id = tune.get_trial_id() # Get current trial ID (e.g., "2c2fc_00000")
|
||||
trial_suffix = trial_id.split("_")[-1] if "_" in trial_id else trial_id
|
||||
config["name"] = f"{base_name}_{trial_suffix}"
|
||||
except Exception:
|
||||
# Not in Ray Tune context or error getting trial ID, use base name
|
||||
config["name"] = base_name
|
||||
|
||||
results = model_to_train.train(**config)
|
||||
return results.results_dict
|
||||
|
||||
# Get search space
|
||||
if not space and not train_args.get("resume"):
|
||||
space = default_space
|
||||
LOGGER.warning("Search space not provided, using default search space.")
|
||||
|
||||
# Get dataset
|
||||
data = train_args.get("data", TASK2DATA[task])
|
||||
space["data"] = data
|
||||
if "data" not in train_args:
|
||||
LOGGER.warning(f'Data not provided, using default "data={data}".')
|
||||
|
||||
# Define the trainable function with allocated resources
|
||||
trainable_with_resources = tune.with_resources(_tune, {"cpu": NUM_THREADS, "gpu": gpu_per_trial or 0})
|
||||
|
||||
# Define the ASHA scheduler for hyperparameter search
|
||||
asha_scheduler = ASHAScheduler(
|
||||
time_attr="epoch",
|
||||
metric=TASK2METRIC[task],
|
||||
mode="max",
|
||||
max_t=train_args.get("epochs") or DEFAULT_CFG_DICT["epochs"] or 100,
|
||||
grace_period=grace_period,
|
||||
reduction_factor=3,
|
||||
)
|
||||
|
||||
# Define the callbacks for the hyperparameter search
|
||||
tuner_callbacks = [WandbLoggerCallback(project="YOLOv8-tune")] if wandb else []
|
||||
|
||||
# Create the Ray Tune hyperparameter search tuner
|
||||
tune_dir = get_save_dir(
|
||||
get_cfg(
|
||||
DEFAULT_CFG,
|
||||
{**train_args, **{"exist_ok": train_args.pop("resume", False)}}, # resume w/ same tune_dir
|
||||
),
|
||||
name=train_args.pop("name", "tune"), # runs/{task}/{tune_dir}
|
||||
) # must be absolute dir
|
||||
tune_dir.mkdir(parents=True, exist_ok=True)
|
||||
if tune.Tuner.can_restore(tune_dir):
|
||||
LOGGER.info(f"{colorstr('Tuner: ')} Resuming tuning run {tune_dir}...")
|
||||
tuner = tune.Tuner.restore(str(tune_dir), trainable=trainable_with_resources, resume_errored=True)
|
||||
else:
|
||||
tuner = tune.Tuner(
|
||||
trainable_with_resources,
|
||||
param_space=space,
|
||||
tune_config=tune.TuneConfig(
|
||||
scheduler=asha_scheduler,
|
||||
num_samples=max_samples,
|
||||
trial_name_creator=lambda trial: f"{trial.trainable_name}_{trial.trial_id}",
|
||||
trial_dirname_creator=lambda trial: f"{trial.trainable_name}_{trial.trial_id}",
|
||||
),
|
||||
run_config=RunConfig(callbacks=tuner_callbacks, storage_path=tune_dir.parent, name=tune_dir.name),
|
||||
)
|
||||
|
||||
# Run the hyperparameter search
|
||||
tuner.fit()
|
||||
|
||||
# Get the results of the hyperparameter search
|
||||
results = tuner.get_results()
|
||||
|
||||
# Shut down Ray to clean up workers
|
||||
ray.shutdown()
|
||||
|
||||
return results
|
||||
115
ultralytics/utils/uploads.py
Executable file
115
ultralytics/utils/uploads.py
Executable file
@@ -0,0 +1,115 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
"""Upload utilities for Ultralytics, mirroring downloads.py patterns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
|
||||
from ultralytics.utils import LOGGER, TQDM
|
||||
|
||||
|
||||
class _ProgressReader:
|
||||
"""File wrapper that reports read progress for upload monitoring."""
|
||||
|
||||
def __init__(self, file_path, pbar):
|
||||
self.file = open(file_path, "rb")
|
||||
self.pbar = pbar
|
||||
self._size = os.path.getsize(file_path)
|
||||
|
||||
def read(self, size=-1):
|
||||
"""Read data and update progress bar."""
|
||||
data = self.file.read(size)
|
||||
if data and self.pbar:
|
||||
self.pbar.update(len(data))
|
||||
return data
|
||||
|
||||
def __len__(self):
|
||||
"""Return file size for Content-Length header."""
|
||||
return self._size
|
||||
|
||||
def close(self):
|
||||
"""Close the file."""
|
||||
self.file.close()
|
||||
|
||||
|
||||
def safe_upload(
|
||||
file: str | Path,
|
||||
url: str,
|
||||
headers: dict | None = None,
|
||||
retry: int = 2,
|
||||
timeout: int = 600,
|
||||
progress: bool = False,
|
||||
) -> bool:
|
||||
"""Upload a file to a URL with retry logic and optional progress bar.
|
||||
|
||||
Args:
|
||||
file (str | Path): Path to the file to upload.
|
||||
url (str): The URL endpoint to upload the file to (e.g., signed GCS URL).
|
||||
headers (dict, optional): Additional headers to include in the request.
|
||||
retry (int, optional): Number of retry attempts on failure (default: 2 for 3 total attempts).
|
||||
timeout (int, optional): Request timeout in seconds.
|
||||
progress (bool, optional): Whether to display a progress bar during upload.
|
||||
|
||||
Returns:
|
||||
(bool): True if upload succeeded, False otherwise.
|
||||
|
||||
Examples:
|
||||
>>> from ultralytics.utils.uploads import safe_upload
|
||||
>>> success = safe_upload("model.pt", "https://storage.googleapis.com/...", progress=True)
|
||||
"""
|
||||
import requests
|
||||
|
||||
file = Path(file)
|
||||
if not file.exists():
|
||||
raise FileNotFoundError(f"File not found: {file}")
|
||||
|
||||
file_size = file.stat().st_size
|
||||
desc = f"Uploading {file.name}"
|
||||
|
||||
# Prepare headers (Content-Length set automatically from file size)
|
||||
upload_headers = {"Content-Type": "application/octet-stream"}
|
||||
if headers:
|
||||
upload_headers.update(headers)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(retry + 1):
|
||||
pbar = None
|
||||
reader = None
|
||||
try:
|
||||
if progress:
|
||||
pbar = TQDM(total=file_size, desc=desc, unit="B", unit_scale=True, unit_divisor=1024)
|
||||
reader = _ProgressReader(file, pbar)
|
||||
|
||||
r = requests.put(url, data=reader, headers=upload_headers, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
reader.close()
|
||||
reader = None # Prevent double-close in finally
|
||||
if pbar:
|
||||
pbar.close()
|
||||
pbar = None
|
||||
LOGGER.info(f"Uploaded {file.name} ✅")
|
||||
return True
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = e.response.status_code if e.response is not None else 0
|
||||
if 400 <= status < 500 and status not in {408, 429}:
|
||||
LOGGER.warning(f"{desc} failed: {status} {getattr(e.response, 'reason', '')}")
|
||||
return False
|
||||
last_error = f"HTTP {status}"
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
finally:
|
||||
if reader:
|
||||
reader.close()
|
||||
if pbar:
|
||||
pbar.close()
|
||||
|
||||
if attempt < retry:
|
||||
wait_time = 2 ** (attempt + 1)
|
||||
LOGGER.warning(f"{desc} failed ({last_error}), retrying {attempt + 1}/{retry} in {wait_time}s...")
|
||||
sleep(wait_time)
|
||||
|
||||
LOGGER.warning(f"{desc} failed after {retry + 1} attempts: {last_error}")
|
||||
return False
|
||||
Reference in New Issue
Block a user