Initial commit

This commit is contained in:
tanexc
2026-06-12 23:53:39 +03:00
commit 67fd2bc5ac
32 changed files with 1107 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
from pathlib import Path
import shutil
from tempfile import TemporaryDirectory
import numpy as np
import onnxruntime as ort
from onnxruntime.quantization import (
CalibrationDataReader,
QuantFormat,
QuantType,
quantize_static,
)
from onnxruntime.tools.convert_onnx_models_to_ort import (
OptimizationStyle,
convert_onnx_models_to_ort,
)
from PIL import Image
from ultralytics import YOLO
from app.domain.ModelConverter import ModelConverter
from app.infrastructure.constants import BASE_DIR
IMAGES_DIR = BASE_DIR / "storage" / "images"
class ModelConverterImpl(ModelConverter):
def convert(self, model_pt_path: Path, results_dir: Path) -> Path:
model_pt_path = Path(model_pt_path)
results_dir = Path(results_dir)
results_dir.mkdir(parents=True, exist_ok=True)
result_path = results_dir / f"{model_pt_path.stem}.ort"
if result_path.exists():
result_path.unlink()
with TemporaryDirectory(dir=results_dir) as temp_dir_name:
temp_dir = Path(temp_dir_name)
fp32_onnx_path = temp_dir / f"{model_pt_path.stem}_fp32.onnx"
int8_onnx_path = temp_dir / f"{model_pt_path.stem}_int8_static.onnx"
model = YOLO(str(model_pt_path), task="detect")
exported_path = Path(
model.export(
format="onnx",
imgsz=640,
opset=20,
dynamic=False,
simplify=False,
half=False,
)
).resolve()
shutil.move(str(exported_path), str(fp32_onnx_path))
calibration_reader = ImageCalibrationDataReader(fp32_onnx_path, IMAGES_DIR)
quantize_static(
model_input=str(fp32_onnx_path),
model_output=str(int8_onnx_path),
calibration_data_reader=calibration_reader,
quant_format=QuantFormat.QDQ,
activation_type=QuantType.QUInt8,
weight_type=QuantType.QInt8,
op_types_to_quantize=["Conv"],
)
convert_onnx_models_to_ort(
int8_onnx_path,
output_dir=temp_dir,
optimization_styles=[OptimizationStyle.Fixed],
)
generated_ort_path = temp_dir / f"{int8_onnx_path.stem}.ort"
generated_ort_path.replace(result_path)
return result_path
class ImageCalibrationDataReader(CalibrationDataReader):
def __init__(self, model_path: Path, images_dir: Path, limit: int = 32):
self.session = ort.InferenceSession(
str(model_path), providers=["CPUExecutionProvider"]
)
self.input_name = self.session.get_inputs()[0].name
self.input_shape = self.session.get_inputs()[0].shape
self.image_paths = [
path
for path in sorted(images_dir.glob("*"))
if path.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"}
][:limit]
self.index = 0
if not self.image_paths:
raise FileNotFoundError(f"calibration images not found in {images_dir}")
def get_next(self) -> dict[str, np.ndarray] | None:
if self.index >= len(self.image_paths):
return None
image_path = self.image_paths[self.index]
self.index += 1
image = Image.open(image_path).convert("RGB")
height = int(self.input_shape[2]) if isinstance(self.input_shape[2], int) else 640
width = int(self.input_shape[3]) if isinstance(self.input_shape[3], int) else 640
scale = min(width / image.width, height / image.height)
resized_width = int(round(image.width * scale))
resized_height = int(round(image.height * scale))
resized_image = image.resize((resized_width, resized_height), Image.BILINEAR)
padded_image = Image.new("RGB", (width, height), (114, 114, 114))
left = (width - resized_width) // 2
top = (height - resized_height) // 2
padded_image.paste(resized_image, (left, top))
image_array = np.asarray(padded_image, dtype=np.float32) / 255.0
image_array = np.transpose(image_array, (2, 0, 1))
return {self.input_name: np.expand_dims(image_array, axis=0)}
def rewind(self) -> None:
self.index = 0
+47
View File
@@ -0,0 +1,47 @@
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.domain.Exception import DuplicateModelVersionError
from app.domain.Model import Model
from app.domain.ModelRepository import ModelRepository
from app.infrastructure.database import ModelRecord
class ModelRepositoryImpl(ModelRepository):
def __init__(self, session: Session) -> None:
self._session = session
def get_latest(self) -> Model | None:
stmt = (
select(ModelRecord)
.order_by(ModelRecord.is_latest.desc(), ModelRecord.created_at.desc())
.limit(1)
)
record = self._session.scalar(stmt)
return record.to_domain() if record else None
def get_by_version(self, version: str) -> Model | None:
stmt = select(ModelRecord).where(ModelRecord.version == version)
record = self._session.scalar(stmt)
return record.to_domain() if record else None
def add(self, model: Model) -> Model:
record = ModelRecord(
version=model.version,
filename=model.filename,
file_path=model.file_path,
created_at=model.created_at,
is_latest=model.is_latest,
)
try:
self._session.execute(update(ModelRecord).values(is_latest=False))
self._session.add(record)
self._session.commit()
self._session.refresh(record)
return record.to_domain()
except IntegrityError as exc:
self._session.rollback()
raise DuplicateModelVersionError(
f"Model version '{model.version}' already exists."
) from exc
+56
View File
@@ -0,0 +1,56 @@
import shutil
from pathlib import Path
from uuid import uuid4
from app.domain.Exception import InvalidModelFileError, ModelFileMissingError
from app.domain.ModelStorage import ModelStorage
from app.infrastructure.constants import TMP_DIR, MODEL_DIR
class ModelStorageImpl(ModelStorage):
@property
def models_dir(self) -> Path:
return MODEL_DIR
def ensure_directories(self) -> None:
MODEL_DIR.mkdir(parents=True, exist_ok=True)
TMP_DIR.mkdir(parents=True, exist_ok=True)
def validate_upload_filename(self, filename: str | None) -> str:
if not filename:
raise InvalidModelFileError("Uploaded file must have a filename.")
safe_name = Path(filename).name
if Path(safe_name).suffix.lower() != ".pt":
raise InvalidModelFileError("Only .pt model files are supported.")
return safe_name
def save_upload_to_tmp(self, source_file, filename: str) -> Path:
tmp_path = TMP_DIR / f"{uuid4().hex}_{filename}"
with tmp_path.open("wb") as buffer:
shutil.copyfileobj(source_file, buffer)
return tmp_path
def build_model_filename(self, version: str) -> str:
return f"model_v{version}.ort"
def prepare_conversion_input(self, tmp_path: Path, model_filename: str) -> Path:
conversion_input_path = TMP_DIR / f"{uuid4().hex}_{Path(model_filename).stem}.pt"
tmp_path.rename(conversion_input_path)
return conversion_input_path
def finalize_converted_model(self, converted_path: Path, model_filename: str) -> Path:
target_path = MODEL_DIR / model_filename
if converted_path.resolve() != target_path.resolve():
converted_path.replace(target_path)
return target_path
def ensure_model_file_exists(self, file_path: str) -> Path:
resolved_path = Path(file_path)
if not resolved_path.exists():
raise ModelFileMissingError("Latest model file is missing on disk.")
return resolved_path
def delete_file_if_exists(self, file_path: Path | None) -> None:
if file_path and file_path.exists():
file_path.unlink()
+1
View File
@@ -0,0 +1 @@
+40
View File
@@ -0,0 +1,40 @@
from dataclasses import dataclass
from pathlib import Path
from app.infrastructure.constants import DATABASE_URL
from app.infrastructure.ModelConverterImpl import ModelConverterImpl
from app.infrastructure.database import Base, create_engine_and_session
from app.infrastructure.ModelStorageImpl import ModelStorageImpl
@dataclass(slots=True)
class Container:
storage: ModelStorageImpl
converter: ModelConverterImpl
engine: object
session_factory: object
_container: Container | None = None
def get_container() -> Container:
global _container
if _container is None:
storage = ModelStorageImpl()
engine, session_factory = create_engine_and_session(DATABASE_URL)
_container = Container(
storage=storage,
converter=ModelConverterImpl(),
engine=engine,
session_factory=session_factory,
)
return _container
def initialize_application() -> None:
container = get_container()
container.storage.ensure_directories()
engine_path = container.engine.url.database
if engine_path:
Path(engine_path).parent.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=container.engine)
+13
View File
@@ -0,0 +1,13 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
MODEL_DIR = Path(os.getenv("MODELS_DIR", str(BASE_DIR / "storage" / "models")))
TMP_DIR = Path(os.getenv("TMP_DIR", str(BASE_DIR / "tmp")))
DATABASE_PATH = BASE_DIR / "db" / "app.db"
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
API_KEY_HEADER_NAME = "X-API-Key"
API_KEYS = tuple(
key.strip() for key in os.getenv("API_KEYS", "").split(",") if key.strip()
)
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from app.domain.Model import Model
class Base(DeclarativeBase):
pass
class ModelRecord(Base):
__tablename__ = "models"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
version: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
filename: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
file_path: Mapped[str] = mapped_column(String(1024), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
is_latest: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
def to_domain(self) -> Model:
return Model(
id=self.id,
version=self.version,
filename=self.filename,
file_path=self.file_path,
created_at=self.created_at,
is_latest=self.is_latest,
)
def create_engine_and_session(database_url: str):
engine = create_engine(
database_url,
connect_args={"check_same_thread": False} if database_url.startswith("sqlite") else {},
)
session_factory = sessionmaker(bind=engine, autocommit=False, autoflush=False)
return engine, session_factory