diff --git a/headroom/image/compressor.py b/headroom/image/compressor.py index 5dc7baa97..448d52be4 100644 --- a/headroom/image/compressor.py +++ b/headroom/image/compressor.py @@ -31,7 +31,10 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .trained_router import TrainedRouter -from .trained_router import Technique +# Import the enum from the dependency-free module so importing the compressor +# does not eagerly import trained_router (and thus torch/transformers) — that +# eager import crashed on Python 3.13+ (#2513). +from .image_types import Technique logger = logging.getLogger(__name__) diff --git a/headroom/image/image_types.py b/headroom/image/image_types.py new file mode 100644 index 000000000..572fcf7f3 --- /dev/null +++ b/headroom/image/image_types.py @@ -0,0 +1,45 @@ +"""Lightweight image-routing types shared across the image stack. + +Kept dependency-free (pure enum + dataclasses, no torch / transformers / onnx) +so importing the image compressor or the ONNX router does not eagerly import +the heavy ML stack via ``trained_router``. On Python 3.13+ that eager import +crashed with ``AttributeError: module 'torch' has no attribute 'compiler'`` +because ``transformers`` touched ``torch.compiler`` before torch finished +initializing inside the proxy process (#2513). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class Technique(Enum): + """Image optimization techniques.""" + + TRANSCODE = "transcode" # Convert to text description (99% savings) + CROP = "crop" # Extract relevant region (50-90% savings) + PRESERVE = "preserve" # Keep full quality (0% savings) + FULL_LOW = "full_low" # Full image, lower quality (87% savings) + + +@dataclass +class ImageSignals: + """Signals extracted from image analysis.""" + + has_text: float + is_document: float + is_complex: float + has_small_details: float + + +@dataclass +class RouteDecision: + """Result of routing decision.""" + + technique: Technique + confidence: float + reason: str + image_signals: ImageSignals | None = None + query_prediction: str | None = None + query_confidence: float | None = None diff --git a/headroom/image/onnx_router.py b/headroom/image/onnx_router.py index 64ceeefa6..509303bac 100644 --- a/headroom/image/onnx_router.py +++ b/headroom/image/onnx_router.py @@ -19,7 +19,7 @@ from typing import Any import numpy as np -from headroom.image.trained_router import ImageSignals, RouteDecision, Technique +from headroom.image.image_types import ImageSignals, RouteDecision, Technique from headroom.onnx_runtime import create_cpu_session_options, hf_hub_download_local_first logger = logging.getLogger(__name__) diff --git a/headroom/image/trained_router.py b/headroom/image/trained_router.py index b53c30d19..dfa16ef70 100644 --- a/headroom/image/trained_router.py +++ b/headroom/image/trained_router.py @@ -12,8 +12,6 @@ from __future__ import annotations import gc import io -from dataclasses import dataclass -from enum import Enum from pathlib import Path from typing import Any @@ -28,6 +26,11 @@ except ImportError: from headroom.models.config import ML_MODEL_DEFAULTS +# Re-exported from the dependency-free module so existing +# ``from .trained_router import Technique`` imports keep working without this +# module (which imports torch/transformers) being needed just for the types. +from .image_types import ImageSignals, RouteDecision, Technique + def _extract_tensor(output: torch.Tensor | BaseModelOutputWithPooling) -> torch.Tensor: """Extract tensor from model output. @@ -55,37 +58,6 @@ def _extract_tensor(output: torch.Tensor | BaseModelOutputWithPooling) -> torch. return output -class Technique(Enum): - """Image optimization techniques.""" - - TRANSCODE = "transcode" # Convert to text description (99% savings) - CROP = "crop" # Extract relevant region (50-90% savings) - PRESERVE = "preserve" # Keep full quality (0% savings) - FULL_LOW = "full_low" # Full image, lower quality (87% savings) - - -@dataclass -class ImageSignals: - """Signals extracted from image analysis.""" - - has_text: float - is_document: float - is_complex: float - has_small_details: float - - -@dataclass -class RouteDecision: - """Result of routing decision.""" - - technique: Technique - confidence: float - reason: str - image_signals: ImageSignals | None = None - query_prediction: str | None = None - query_confidence: float | None = None - - class TrainedRouter: """Router using trained MiniLM classifier + SigLIP image analysis. diff --git a/tests/test_image_types_torch_decoupling.py b/tests/test_image_types_torch_decoupling.py new file mode 100644 index 000000000..4c7f37426 --- /dev/null +++ b/tests/test_image_types_torch_decoupling.py @@ -0,0 +1,55 @@ +"""Importing the image compressor must not eagerly import trained_router (torch). + +#2513 (secondary): ``from .trained_router import Technique`` at module scope +pulled in torch / transformers, which on Python 3.13+ crashed with +``AttributeError: module 'torch' has no attribute 'compiler'`` before torch had +finished initializing. The routing types now live in a dependency-free module. + +The "does not import" checks run in a fresh subprocess so module caching from +other tests can't mask the import graph. +""" + +from __future__ import annotations + +import subprocess +import sys + + +def _assert_import_excludes(module_name: str, excluded: str) -> None: + code = ( + "import sys, importlib\n" + f"importlib.import_module({module_name!r})\n" + f"assert {excluded!r} not in sys.modules, {excluded!r} + ' was imported'\n" + "print('ok')\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, timeout=120 + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "ok" + + +def test_importing_compressor_does_not_import_trained_router() -> None: + _assert_import_excludes("headroom.image.compressor", "headroom.image.trained_router") + + +def test_importing_onnx_router_does_not_import_trained_router() -> None: + _assert_import_excludes("headroom.image.onnx_router", "headroom.image.trained_router") + + +def test_image_types_module_does_not_import_torch() -> None: + _assert_import_excludes("headroom.image.image_types", "torch") + + +def test_technique_reexports_are_the_same_object() -> None: + from headroom.image import Technique as via_package + from headroom.image.image_types import ImageSignals, RouteDecision, Technique + from headroom.image.trained_router import ImageSignals as tr_signals + from headroom.image.trained_router import RouteDecision as tr_decision + from headroom.image.trained_router import Technique as tr_technique + + assert via_package is Technique + assert tr_technique is Technique + assert tr_signals is ImageSignals + assert tr_decision is RouteDecision + assert Technique.PRESERVE.value == "preserve"