Skip to content

Commit 42dc6b0

Browse files
kprokofiwarrkan
andauthored
Integrate Ultralytics Backend (OD + IS) (#6648)
Co-authored-by: Romanov, Evgeny <evgeny.romanov@intel.com>
1 parent b060821 commit 42dc6b0

94 files changed

Lines changed: 12738 additions & 918 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

application/backend/app/execution/common/geti_config_converter.py

Lines changed: 252 additions & 51 deletions
Large diffs are not rendered by default.

application/backend/app/execution/common/getitune_converters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def convert_metrics(metrics: dict) -> dict[str, float]:
6666
"""
6767
result: dict[str, float] = {}
6868
for k, v in metrics.items():
69-
name = k.split("/")[1] if "/" in k else k
69+
name = k.split("/", 1)[1] if "/" in k else k
7070
if isinstance(v, torch.Tensor):
7171
if v.numel() == 1:
7272
result[name] = v.item()

application/backend/app/execution/quantization/getitune_quantizer.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
from uuid import UUID
99

10+
import yaml
1011
from datumaro.experimental.fields import Subset
1112
from getitune.backend.openvino.engine import OVEngine
1213
from getitune.config.data import SamplerConfig, SubsetConfig
@@ -56,8 +57,12 @@ def __init__(self, quantization_deps: QuantizationDependencies) -> None:
5657
self._db_session_factory = quantization_deps.db_session_factory
5758

5859
@step("Validate Model", 5)
59-
def validate_model(self, params: QuantizationJobParams) -> ModelRevision:
60-
"""Verify the source model is valid for quantization."""
60+
def validate_model(self, params: QuantizationJobParams) -> tuple[ModelRevision, ModelVariant]:
61+
"""Verify the source model is valid for quantization.
62+
63+
Returns:
64+
Tuple of (model, openvino_fp16_variant).
65+
"""
6166
with self._db_session_factory() as db:
6267
self._model_service.set_db_session(db)
6368
model = self._model_service.get_model(project_id=params.project_id, model_id=params.model_id)
@@ -82,8 +87,18 @@ def validate_model(self, params: QuantizationJobParams) -> ModelRevision:
8287
if not ov_model_xml_path.exists():
8388
raise FileNotFoundError(f"OpenVINO model files not found at {ov_model_xml_path}")
8489

90+
# Validate metadata.yaml structure if it exists (optional — Lightning models don't produce it)
91+
metadata_path = ov_model_xml_path.parent / "metadata.yaml"
92+
if metadata_path.exists():
93+
with open(metadata_path) as f:
94+
metadata = yaml.safe_load(f)
95+
if not isinstance(metadata, dict):
96+
raise ValueError(f"metadata.yaml is malformed (not a dict) at {metadata_path}")
97+
if "args" not in metadata or not isinstance(metadata["args"], dict):
98+
raise ValueError(f"metadata.yaml is missing 'args' dict at {metadata_path}")
99+
85100
logger.info("Model {} validated for quantization (architecture={})", model.id, model.architecture)
86-
return model
101+
return model, openvino_variant
87102

88103
@step("Prepare Calibration Dataset", 20)
89104
def prepare_calibration_dataset(
@@ -259,6 +274,7 @@ def store_artifacts(
259274
params: QuantizationJobParams,
260275
quantized_model_path: Path,
261276
model_variant_id: UUID,
277+
source_variant_id: UUID,
262278
) -> None:
263279
"""Store quantized model files.
264280
@@ -274,11 +290,25 @@ def store_artifacts(
274290
bin_path = quantized_model_path.with_suffix(".bin")
275291
if bin_path.exists():
276292
shutil.copyfile(bin_path, variant_dir / "model.bin")
293+
294+
# Copy metadata.yaml from the source FP16 variant and update for INT8
295+
source_variant_dir = model_dir / "variants" / str(source_variant_id)
296+
source_metadata_path = source_variant_dir / "metadata.yaml"
297+
if source_metadata_path.exists():
298+
with open(source_metadata_path) as f:
299+
metadata = yaml.safe_load(f)
300+
metadata["args"]["int8"] = True
301+
metadata["args"]["half"] = False
302+
dest_metadata_path = variant_dir / "metadata.yaml"
303+
with open(dest_metadata_path, "w") as f:
304+
yaml.dump(metadata, f, default_flow_style=False, sort_keys=False)
305+
logger.info("Wrote INT8 metadata.yaml to {}", dest_metadata_path)
306+
277307
logger.info("Stored quantized model variant at {}", variant_dir)
278308

279309
def execute(self, params: QuantizationJobParams) -> None:
280310
"""Execute the full quantization pipeline."""
281-
model = self.validate_model(params=params)
311+
model, openvino_fp16_variant = self.validate_model(params=params)
282312

283313
with self._db_session_factory() as db:
284314
self._project_service.set_db_session(db)
@@ -323,6 +353,7 @@ def execute(self, params: QuantizationJobParams) -> None:
323353
params=params,
324354
quantized_model_path=quantized_model_path,
325355
model_variant_id=variant.id,
356+
source_variant_id=openvino_fp16_variant.id,
326357
)
327358

328359
def _base_model_path(self, project_id: UUID, model_id: UUID) -> Path:

application/backend/app/execution/training/getitune_trainer.py

Lines changed: 70 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,24 @@
77
from dataclasses import dataclass
88
from datetime import UTC, datetime
99
from pathlib import Path
10-
from typing import cast
10+
from typing import Any, cast
1111
from uuid import UUID
1212

1313
import polars as pl
1414
import yaml
1515
from datumaro.experimental import Dataset
1616
from datumaro.experimental.fields import Subset
1717
from getitune import TaskType
18-
from getitune.backend.lightning.engine import LightningEngine
1918
from getitune.backend.lightning.models.base import DataInputParams, LightningModel
2019
from getitune.backend.openvino.engine import OVEngine
20+
from getitune.backend.ultralytics.models.base import UltralyticsModel
2121
from getitune.config.data import SamplerConfig, SubsetConfig
2222
from getitune.data.dataset.base import VisionDataset
2323
from getitune.data.entity.utils import detect_storage_dtype
2424
from getitune.data.factory import TransformLibFactory
2525
from getitune.data.module import DataModule
26+
from getitune.engine import create_engine
27+
from getitune.engine.engine import Engine
2628
from getitune.types.device import DeviceType as GetiTuneDeviceType
2729
from getitune.types.export import ExportFormat
2830
from getitune.types.precision import Precision
@@ -171,7 +173,12 @@ def prepare_weights(self, training_params: TrainingJobParams) -> Path:
171173
"Can't start training - the parent revision has no variants (it may have failed). "
172174
"Review the previous revision and retry."
173175
)
174-
parent_pytorch_variant = next(v for v in parent_variants if v.format == ModelFormat.PYTORCH)
176+
parent_pytorch_variant = next((v for v in parent_variants if v.format == ModelFormat.PYTORCH), None)
177+
if parent_pytorch_variant is None:
178+
raise ExecutionErr(
179+
"Can't start training - the parent revision has no PyTorch variant. "
180+
"Review the previous revision and retry."
181+
)
175182
weights_path = self.__build_model_weights_path(
176183
self._data_dir, project_id, parent_model_revision_id, parent_pytorch_variant.id
177184
)
@@ -384,20 +391,17 @@ def train_model(
384391
weights_path: Path,
385392
model_id: UUID,
386393
device: DeviceInfo,
387-
has_parent_revision: bool,
388-
) -> tuple[Path, LightningEngine]:
389-
"""Execute model training."""
390-
# TODO use weights path to initialize model from pre-downloaded weights
391-
# after resolving https://github.com/open-edge-platform/training_extensions/issues/5100
392-
logger.warning(
393-
"Argument 'weights_path' (value='{}') is not used in model training yet; "
394-
"the weights location will be determined internally by getitune",
395-
weights_path,
396-
)
397-
398-
# Build the DataModule
394+
) -> tuple[Path, Engine]:
395+
"""Execute model training.
396+
397+
Instantiates the model from *training_config* with jsonargparse and
398+
creates the engine through ``create_engine`` from the library.
399+
Lightning callbacks are built when a ``callbacks`` section is present
400+
in the config; backends that do not support them simply ignore the
401+
parameter.
402+
"""
399403
logger.info("Preparing the DataModule for training (model_id={})", model_id)
400-
getitune_datamodule = DataModule.from_vision_datasets(
404+
datamodule = DataModule.from_vision_datasets(
401405
train_dataset=dataset_info.getitune_training_dataset,
402406
val_dataset=dataset_info.getitune_validation_dataset,
403407
test_dataset=dataset_info.getitune_testing_dataset,
@@ -406,36 +410,36 @@ def train_model(
406410
test_subset=dataset_info.getitune_testing_subset_config,
407411
)
408412

409-
# Create the LightningModel according to the training configuration
410-
logger.info("Instantiating the LightningModel for training (model_id={})", model_id)
413+
logger.info("Instantiating model for training (model_id={})", model_id)
411414
model_cfg = training_config["model"]
412-
model_cfg["init_args"]["label_info"] = getitune_datamodule.label_info.label_names
415+
model_cfg["init_args"]["label_info"] = datamodule.label_info.label_names
413416
model_cfg["init_args"]["data_input_params"] = DataInputParams(
414-
input_size=cast(tuple[int, int], getitune_datamodule.input_size),
415-
mean=getitune_datamodule.input_mean if getitune_datamodule.input_mean is not None else (0.0, 0.0, 0.0),
416-
std=getitune_datamodule.input_std if getitune_datamodule.input_std is not None else (1.0, 1.0, 1.0),
417-
intensity_config=getitune_datamodule.input_intensity_config,
417+
input_size=cast(tuple[int, int], datamodule.input_size),
418+
mean=datamodule.input_mean if datamodule.input_mean is not None else (0.0, 0.0, 0.0),
419+
std=datamodule.input_std if datamodule.input_std is not None else (1.0, 1.0, 1.0),
420+
intensity_config=datamodule.input_intensity_config,
418421
).as_dict()
419-
model_parser = ArgumentParser()
420-
model_parser.add_subclass_arguments(LightningModel, "model", required=False, fail_untyped=False)
421-
getitune_model: LightningModel = model_parser.instantiate_classes(Namespace(model=model_cfg)).get("model")
422-
if hasattr(getitune_model, "tile_config"):
423-
getitune_model.tile_config = getitune_datamodule.tile_config
424-
425-
# Set up the LightningEngine
426-
logger.info("Initializing the LightningEngine for training (model_id={})", model_id)
422+
logger.info("Initializing engine for training (model_id={})", model_id)
427423
getitune_device_type = (
428424
GetiTuneDeviceType.gpu if device.type is DeviceType.CUDA else GetiTuneDeviceType(device.type)
429425
)
430-
getitune_engine = LightningEngine(
431-
model=getitune_model,
432-
data=getitune_datamodule,
433-
checkpoint=weights_path if has_parent_revision else None,
434-
work_dir=self._data_dir / f"getitune-workspace-{model_id}",
435-
device=getitune_device_type,
436-
)
426+
engine_kwargs: dict[str, Any] = {
427+
"work_dir": self._data_dir / f"getitune-workspace-{model_id}",
428+
"device": getitune_device_type,
429+
"checkpoint": weights_path,
430+
}
431+
432+
model_parser = ArgumentParser()
433+
class_path = model_cfg.get("class_path", "")
434+
model_type = UltralyticsModel if "ultralytics" in class_path else LightningModel
435+
model_parser.add_argument("--model", type=model_type)
436+
getitune_model = model_parser.instantiate_classes(Namespace(model=model_cfg)).get("model")
437+
438+
if hasattr(getitune_model, "tile_config"):
439+
getitune_model.tile_config = datamodule.tile_config
440+
441+
getitune_engine = create_engine(model=getitune_model, data=datamodule, **engine_kwargs)
437442

438-
# Set up the callbacks
439443
callbacks_cfg = training_config.get("callbacks", [])
440444
for cb_cfg in callbacks_cfg:
441445
if "init_args" in cb_cfg and "dirpath" in cb_cfg["init_args"]:
@@ -446,25 +450,35 @@ def train_model(
446450
callbacks_list = parser.instantiate_classes(parsed_callbacks_cfg).get("callbacks", [])
447451
callbacks_list.append(TrainingProgressCallback(self.update_progress, min_p=10, max_p=80))
448452

449-
# Start training
450-
logger.info("Starting the training loop (model_id={})", model_id)
451-
train_kwargs = {
453+
logger.info("Starting training loop (model_id={})", model_id)
454+
train_kwargs: dict[str, Any] = {
452455
"max_epochs": training_config["max_epochs"],
453456
"callbacks": callbacks_list,
454457
}
455-
if device.type is not DeviceType.CPU and device.index:
458+
if device.type is not DeviceType.CPU and device.index is not None:
456459
train_kwargs["devices"] = [device.index]
457460
if "precision" in training_config:
458461
train_kwargs["precision"] = training_config["precision"]
462+
# Forward backend-specific training args (e.g. patience for Ultralytics).
463+
if "training" in training_config:
464+
train_kwargs.update(training_config["training"])
459465
getitune_engine.train(**train_kwargs) # pyrefly: ignore[bad-argument-type]
460-
trained_model_path = Path(getitune_engine.work_dir) / "best_checkpoint.ckpt"
466+
467+
trained_model_path = getitune_engine.best_checkpoint
468+
if trained_model_path is None:
469+
raise RuntimeError(
470+
f"Training completed but no best checkpoint was produced "
471+
f"by the engine ({type(getitune_engine).__name__})."
472+
)
473+
if not trained_model_path.exists():
474+
raise FileNotFoundError(f"Trained checkpoint not found at {trained_model_path}")
461475
logger.info("Model training completed. Trained model saved at {}", trained_model_path)
462476
return trained_model_path, getitune_engine
463477

464478
@step("Evaluate Model", 95)
465479
def evaluate_model(
466480
self,
467-
getitune_engine: LightningEngine,
481+
getitune_engine: Engine,
468482
task: Task,
469483
model_revision_id: UUID,
470484
model_variants: list[ModelVariantDescriptor],
@@ -533,7 +547,7 @@ def _save_evaluation_result(
533547
)
534548

535549
@step("Export Model")
536-
def export_model(self, getitune_engine: LightningEngine, model_checkpoint_path: Path) -> ExportedModels:
550+
def export_model(self, getitune_engine: Engine, model_checkpoint_path: Path) -> ExportedModels:
537551
"""Export the trained model to desired OpenVINO and ONNX formats"""
538552
logger.info("Exporting the model to OpenVINO format (FP16 precision)...")
539553
exported_ov_model_path = getitune_engine.export(
@@ -578,10 +592,9 @@ def store_model_artifacts(
578592
variants_dir = model_dir / "variants"
579593
variants_dir.mkdir(parents=True, exist_ok=True)
580594

581-
# Copy PyTorch checkpoint
582595
pytorch_variant_dir = variants_dir / str(created_variants[ModelFormat.PYTORCH])
583596
pytorch_variant_dir.mkdir(parents=True, exist_ok=True)
584-
shutil.copyfile(trained_model_path, pytorch_variant_dir / "model.ckpt")
597+
shutil.copyfile(trained_model_path, pytorch_variant_dir / "model.pt")
585598
logger.info("Stored PyTorch variant at {}", pytorch_variant_dir)
586599

587600
# Copy OpenVINO IR files
@@ -595,6 +608,9 @@ def store_model_artifacts(
595608
exported_model_paths.openvino_model_path.with_suffix(".bin"),
596609
openvino_variant_dir / "model.bin",
597610
)
611+
ov_metadata = exported_model_paths.openvino_model_path.parent / "metadata.yaml"
612+
if ov_metadata.exists():
613+
shutil.copyfile(ov_metadata, openvino_variant_dir / "metadata.yaml")
598614
logger.info("Stored OpenVINO variant at {}", openvino_variant_dir)
599615

600616
# Copy ONNX file
@@ -604,14 +620,16 @@ def store_model_artifacts(
604620
exported_model_paths.onnx_model_path.with_suffix(".onnx"),
605621
onnx_variant_dir / "model.onnx",
606622
)
623+
onnx_metadata = exported_model_paths.onnx_model_path.parent / "metadata.yaml"
624+
if onnx_metadata.exists():
625+
shutil.copyfile(onnx_metadata, onnx_variant_dir / "metadata.yaml")
607626
logger.info("Stored ONNX variant at {}", onnx_variant_dir)
608627

609628
# Store the metrics
610629
metrics_source_path = getitune_work_dir / "csv"
611630
metrics_dest_path = model_dir / "metrics"
612631
if metrics_source_path.exists():
613632
shutil.move(metrics_source_path, metrics_dest_path)
614-
logger.info("Stored training metrics at {}", metrics_dest_path)
615633

616634
def create_model_variants(self, model_revision_id: UUID) -> dict[ModelFormat, UUID]:
617635
"""Create variant records in the database for all exported formats.
@@ -680,7 +698,6 @@ def execute(self, params: TrainingJobParams) -> None:
680698
weights_path=weights_path,
681699
model_id=params.model_id,
682700
device=params.device,
683-
has_parent_revision=params.parent_model_revision_id is not None,
684701
)
685702
exported_model_paths = self.export_model(
686703
getitune_engine=getitune_engine, model_checkpoint_path=trained_model_path
@@ -849,9 +866,10 @@ def __base_model_path(data_dir: Path, project_id: UUID, model_id: UUID) -> Path:
849866

850867
@classmethod
851868
def __build_model_weights_path(cls, data_dir: Path, project_id: UUID, model_id: UUID, model_variant: UUID) -> Path:
852-
"""Get the path to the PyTorch checkpoint from a model's variants directory."""
869+
"""Get the path to the stored PyTorch checkpoint."""
853870
model_dir = cls.__base_model_path(data_dir, project_id, model_id)
854-
return model_dir / "variants" / str(model_variant) / "model.ckpt"
871+
variant_dir = model_dir / "variants" / str(model_variant)
872+
return variant_dir / "model.pt"
855873

856874
@classmethod
857875
def __build_model_config_path(cls, data_dir: Path, project_id: UUID, model_id: UUID) -> Path:

application/backend/app/models/training_configuration/augmentation.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -371,19 +371,20 @@ class Mosaic(BaseAugmentationParameter):
371371
default=0.5,
372372
title="Scale",
373373
description=(
374-
"Scale factor for random perspective crop applied after mosaic assembly. "
375-
"The random scale is sampled from [1-scale, 1+scale]. "
376-
"Higher values produce more zoom variation."
374+
"Scale factor for the random perspective crop applied after mosaic assembly. "
375+
"The random scale is sampled from [1 - scale, 1 + scale]. "
376+
"Higher values produce more aggressive zoom variation."
377377
),
378378
)
379379
translate: float = Field(
380380
ge=0.0,
381-
le=0.5,
381+
le=1.0,
382382
default=0.1,
383383
title="Translate",
384384
description=(
385-
"Translation fraction for random perspective crop. "
386-
"The crop center shifts by ±translate * image_size pixels."
385+
"Translate fraction for the random perspective crop. "
386+
"The crop centre shifts by up to +/- translate * image_size pixels. "
387+
"Higher values allow more positional variation."
387388
),
388389
)
389390

application/backend/app/services/data_collect/prediction_converter.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,7 @@ def _find_project_label(
5252
label_name: str | None,
5353
predicted_class_index: int | None = None,
5454
) -> Label | None:
55-
"""
56-
Find a project label matching the predicted label using a three-stage fallback strategy.
55+
"""Find a project label matching the predicted label using a three-stage fallback strategy.
5756
5857
Resolution order:
5958
1. **Exact name match** - performs an O(1) lookup of ``label_name`` in ``name_map``.

application/backend/app/services/demo_files_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ def main() -> None:
318318
319319
dependencies = [
320320
"openvino~=2026.1.0",
321-
"openvino-model-api[onnx]~=0.4.3",
321+
"openvino-model-api[onnx]~=0.4.5",
322322
"opencv-python-headless~=4.13.0",
323323
"numpy>=2.0",
324324
"pillow~=12.0",

application/backend/app/services/inference/inference_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def set_inference_model(
116116
resource_type=ResourceType.MODEL,
117117
resource_id=f"{model_id} variant {model_variant.id}",
118118
)
119-
model_xml_path, _ = paths
119+
model_xml_path = paths[0]
120120

121121
if self._loaded_model is not None:
122122
ModelLoader.unload(self._loaded_model)

0 commit comments

Comments
 (0)