Spaces:
Running
Running
senthil2421 commited on
Commit Β·
8302f42
1
Parent(s): 99e3f1b
fix: remove project dependencies and redundant imports to fix server startup
Browse files- __pycache__/config.cpython-310.pyc +0 -0
- __pycache__/main.cpython-310.pyc +0 -0
- adapters/__init__.py +0 -0
- adapters/__pycache__/__init__.cpython-310.pyc +0 -0
- adapters/__pycache__/base.cpython-310.pyc +0 -0
- adapters/__pycache__/hf_adapter.cpython-310.pyc +0 -0
- adapters/__pycache__/onnx_adapter.cpython-310.pyc +0 -0
- adapters/__pycache__/roboflow_adapter.cpython-310.pyc +0 -0
- adapters/base.py +28 -0
- adapters/hf_adapter.py +415 -0
- adapters/onnx_adapter.py +176 -0
- adapters/roboflow_adapter.py +353 -0
- api/routes/__pycache__/datasets.cpython-310.pyc +0 -0
- api/routes/__pycache__/models.cpython-310.pyc +0 -0
- api/routes/__pycache__/sync.cpython-310.pyc +0 -0
- api/routes/datasets.py +6 -275
- api/routes/models.py +5 -77
- api/routes/projects.py +0 -54
- config.py +2 -0
- datasets/import_service.py +0 -589
- datasets/viewer_service.py +0 -320
- models/__pycache__/analytics.cpython-310.pyc +0 -0
- models/project.py +0 -15
- projects/__init__.py +0 -0
- projects/__pycache__/__init__.cpython-310.pyc +0 -0
- projects/__pycache__/registry.cpython-310.pyc +0 -0
- projects/__pycache__/service.cpython-310.pyc +0 -0
- projects/registry.py +73 -0
- projects/service.py +186 -0
__pycache__/config.cpython-310.pyc
CHANGED
|
Binary files a/__pycache__/config.cpython-310.pyc and b/__pycache__/config.cpython-310.pyc differ
|
|
|
__pycache__/main.cpython-310.pyc
CHANGED
|
Binary files a/__pycache__/main.cpython-310.pyc and b/__pycache__/main.cpython-310.pyc differ
|
|
|
adapters/__init__.py
ADDED
|
File without changes
|
adapters/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (143 Bytes). View file
|
|
|
adapters/__pycache__/base.cpython-310.pyc
ADDED
|
Binary file (1.31 kB). View file
|
|
|
adapters/__pycache__/hf_adapter.cpython-310.pyc
ADDED
|
Binary file (13 kB). View file
|
|
|
adapters/__pycache__/onnx_adapter.cpython-310.pyc
ADDED
|
Binary file (5.27 kB). View file
|
|
|
adapters/__pycache__/roboflow_adapter.cpython-310.pyc
ADDED
|
Binary file (10.9 kB). View file
|
|
|
adapters/base.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
adapters/base.py β Abstract base class every source adapter must implement.
|
| 3 |
+
Enforces a stable contract so the registry never knows which adapter runs.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from abc import ABC, abstractmethod
|
| 8 |
+
|
| 9 |
+
from models.model import Model
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class BaseAdapter(ABC):
|
| 13 |
+
"""Fetch models from an external source and normalize to the Model schema."""
|
| 14 |
+
|
| 15 |
+
source_name: str = "unknown"
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
async def fetch_models(self) -> list[Model]:
|
| 19 |
+
"""Return a list of normalized Model objects from the source."""
|
| 20 |
+
...
|
| 21 |
+
|
| 22 |
+
def _format_size(self, bytes_: int) -> str:
|
| 23 |
+
"""Human-readable file size."""
|
| 24 |
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
| 25 |
+
if bytes_ < 1024:
|
| 26 |
+
return f"{bytes_:.1f} {unit}"
|
| 27 |
+
bytes_ //= 1024
|
| 28 |
+
return f"{bytes_} PB"
|
adapters/hf_adapter.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
adapters/hf_adapter.py β Hugging Face Hub adapter.
|
| 3 |
+
Fetches real models via the public HF API and normalises them to our schema.
|
| 4 |
+
|
| 5 |
+
Rate-limits respected via polite delays. Requires no authentication for
|
| 6 |
+
publicly accessible models; set HF_TOKEN env var for higher rate-limits.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import re
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _is_shard_file(filename: str) -> bool:
|
| 16 |
+
"""Return True for sharded weight files like model-00001-of-00003.safetensors."""
|
| 17 |
+
return bool(re.search(r"-\d{5}-of-\d{5}\.", filename))
|
| 18 |
+
|
| 19 |
+
import httpx
|
| 20 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 21 |
+
|
| 22 |
+
from adapters.base import BaseAdapter
|
| 23 |
+
from config import settings
|
| 24 |
+
from models.model import Model, ModelMetrics, ModelVersion
|
| 25 |
+
from observability.logger import get_logger
|
| 26 |
+
|
| 27 |
+
log = get_logger("hf_adapter")
|
| 28 |
+
|
| 29 |
+
# ββ Task mapping: HF pipeline_tag β our internal task βββββββββββββββββββββββββ
|
| 30 |
+
HF_TASK_MAP: dict[str, str] = {
|
| 31 |
+
"object-detection": "detection",
|
| 32 |
+
"image-classification": "classification",
|
| 33 |
+
"image-segmentation": "segmentation",
|
| 34 |
+
"text-to-image": "generation",
|
| 35 |
+
"image-to-image": "generation",
|
| 36 |
+
"image-feature-extraction": "embedding",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# Tasks we actively fetch
|
| 40 |
+
FETCH_TASKS: list[str] = list(HF_TASK_MAP.keys())
|
| 41 |
+
|
| 42 |
+
# ββ Framework detection ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
def _detect_framework(tags: list[str], model_id: str) -> str:
|
| 44 |
+
tag_str = " ".join(tags + [model_id]).lower()
|
| 45 |
+
if "onnx" in tag_str: return "onnx"
|
| 46 |
+
if "tflite" in tag_str: return "tflite"
|
| 47 |
+
if "coreml" in tag_str: return "coreml"
|
| 48 |
+
if "tensorflow" in tag_str or "tf" in tag_str: return "tensorflow"
|
| 49 |
+
return "pytorch" # HF default
|
| 50 |
+
|
| 51 |
+
# ββ Hardware detection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
def _detect_hardware(tags: list[str]) -> list[str]:
|
| 53 |
+
hw: list[str] = []
|
| 54 |
+
tag_str = " ".join(tags).lower()
|
| 55 |
+
if any(k in tag_str for k in ("cuda", "gpu")): hw.append("gpu")
|
| 56 |
+
if "edge" in tag_str or "mobile" in tag_str: hw.append("edge")
|
| 57 |
+
if "cpu" in tag_str: hw.append("cpu")
|
| 58 |
+
if not hw: hw.append("gpu") # safe default
|
| 59 |
+
return hw
|
| 60 |
+
|
| 61 |
+
# ββ Internal tag normalisation βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
QUALITY_TAG_MAP = {
|
| 63 |
+
"state-of-the-art": "sota",
|
| 64 |
+
"lightweight": "lightweight",
|
| 65 |
+
"tiny": "tiny",
|
| 66 |
+
"fast": "fastest",
|
| 67 |
+
"real-time": "real-time",
|
| 68 |
+
"accuracy": "high-accuracy",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
def _normalise_tags(raw_tags: list[str], pipeline: str) -> list[str]:
|
| 72 |
+
out: list[str] = []
|
| 73 |
+
for t in raw_tags:
|
| 74 |
+
t_lower = t.lower()
|
| 75 |
+
for keyword, mapped in QUALITY_TAG_MAP.items():
|
| 76 |
+
if keyword in t_lower:
|
| 77 |
+
out.append(mapped)
|
| 78 |
+
# keep relevant library / dataset tags
|
| 79 |
+
if any(t_lower.startswith(p) for p in ("dataset:", "license:", "language:")):
|
| 80 |
+
continue
|
| 81 |
+
out.append(t_lower)
|
| 82 |
+
# add pipeline as tag
|
| 83 |
+
if pipeline:
|
| 84 |
+
out.append(pipeline.replace("-", "_"))
|
| 85 |
+
return list(dict.fromkeys(out)) # deduplicate, preserve order
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class HFAdapter(BaseAdapter):
|
| 89 |
+
source_name = "hf"
|
| 90 |
+
|
| 91 |
+
def __init__(self) -> None:
|
| 92 |
+
headers = {"Accept": "application/json"}
|
| 93 |
+
if settings.hf_token:
|
| 94 |
+
headers["Authorization"] = f"Bearer {settings.hf_token}"
|
| 95 |
+
self._client = httpx.AsyncClient(
|
| 96 |
+
base_url=settings.hf_api_base,
|
| 97 |
+
headers=headers,
|
| 98 |
+
timeout=30,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
@retry(
|
| 102 |
+
stop=stop_after_attempt(3),
|
| 103 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 104 |
+
reraise=True,
|
| 105 |
+
)
|
| 106 |
+
async def _fetch_task_page(
|
| 107 |
+
self, pipeline_tag: str, limit: int = 100
|
| 108 |
+
) -> list[dict[str, Any]]:
|
| 109 |
+
params = {
|
| 110 |
+
"pipeline_tag": pipeline_tag,
|
| 111 |
+
"sort": "downloads",
|
| 112 |
+
"direction": -1, # descending
|
| 113 |
+
"limit": limit,
|
| 114 |
+
"full": "True",
|
| 115 |
+
}
|
| 116 |
+
log.info("hf_fetch_task", pipeline_tag=pipeline_tag, limit=limit)
|
| 117 |
+
resp = await self._client.get("/models", params=params)
|
| 118 |
+
resp.raise_for_status()
|
| 119 |
+
return resp.json()
|
| 120 |
+
|
| 121 |
+
@retry(
|
| 122 |
+
stop=stop_after_attempt(3),
|
| 123 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 124 |
+
reraise=True,
|
| 125 |
+
)
|
| 126 |
+
async def _fetch_model_detail(self, model_id: str) -> dict[str, Any]:
|
| 127 |
+
resp = await self._client.get(f"/models/{model_id}", params={"full": "True"})
|
| 128 |
+
resp.raise_for_status()
|
| 129 |
+
raw = resp.json()
|
| 130 |
+
|
| 131 |
+
siblings: list[dict[str, Any]] = raw.get("siblings") or []
|
| 132 |
+
has_any_size = any(isinstance(s, dict) and s.get("size") for s in siblings)
|
| 133 |
+
if not has_any_size:
|
| 134 |
+
try:
|
| 135 |
+
tree = await self._fetch_model_tree(model_id, revision="main")
|
| 136 |
+
size_by_path: dict[str, int] = {
|
| 137 |
+
(t.get("path") or ""): int(t.get("size") or 0)
|
| 138 |
+
for t in (tree or [])
|
| 139 |
+
if isinstance(t, dict)
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
patched: list[dict[str, Any]] = []
|
| 143 |
+
for s in siblings:
|
| 144 |
+
if not isinstance(s, dict):
|
| 145 |
+
continue
|
| 146 |
+
fn = s.get("rfilename") or s.get("path") or ""
|
| 147 |
+
if fn and not s.get("size") and fn in size_by_path:
|
| 148 |
+
s = {**s, "size": size_by_path[fn]}
|
| 149 |
+
patched.append(s)
|
| 150 |
+
raw["siblings"] = patched
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
|
| 154 |
+
return raw
|
| 155 |
+
|
| 156 |
+
@retry(
|
| 157 |
+
stop=stop_after_attempt(3),
|
| 158 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 159 |
+
reraise=True,
|
| 160 |
+
)
|
| 161 |
+
async def _fetch_model_tree(self, model_id: str, *, revision: str = "main") -> list[dict[str, Any]]:
|
| 162 |
+
resp = await self._client.get(f"/models/{model_id}/tree/{revision}")
|
| 163 |
+
resp.raise_for_status()
|
| 164 |
+
data = resp.json()
|
| 165 |
+
if isinstance(data, list):
|
| 166 |
+
return data
|
| 167 |
+
return []
|
| 168 |
+
|
| 169 |
+
def _parse_safe_tensors_size(self, siblings: list[dict]) -> int:
|
| 170 |
+
"""Estimate model size from sibling file list."""
|
| 171 |
+
total = 0
|
| 172 |
+
weight_exts = (".pt", ".pth", ".safetensors", ".bin", ".onnx", ".tflite", ".mlmodel")
|
| 173 |
+
for s in siblings or []:
|
| 174 |
+
filename = s.get("rfilename", "").lower()
|
| 175 |
+
if filename.endswith(weight_exts):
|
| 176 |
+
total += s.get("size", 0)
|
| 177 |
+
|
| 178 |
+
if total > 0:
|
| 179 |
+
return total
|
| 180 |
+
|
| 181 |
+
# If no size found in siblings, check if it's in the root dict (sometimes HF API does this)
|
| 182 |
+
return 0 # Return 0 if not found, we'll handle fallback in _make_model
|
| 183 |
+
|
| 184 |
+
@retry(
|
| 185 |
+
stop=stop_after_attempt(3),
|
| 186 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 187 |
+
reraise=True,
|
| 188 |
+
)
|
| 189 |
+
async def _fetch_model_card(self, model_id: str) -> str:
|
| 190 |
+
"""Fetch model card (README.md) content for real-time description."""
|
| 191 |
+
url = f"{settings.hf_hub_url}/{model_id}/raw/main/README.md"
|
| 192 |
+
try:
|
| 193 |
+
resp = await self._client.get(url)
|
| 194 |
+
if resp.status_code == 200:
|
| 195 |
+
return resp.text
|
| 196 |
+
except Exception:
|
| 197 |
+
pass
|
| 198 |
+
return ""
|
| 199 |
+
|
| 200 |
+
def _extract_description(self, readme: str, raw: dict[str, Any]) -> str:
|
| 201 |
+
"""Extract a clean description from README or card data."""
|
| 202 |
+
if readme:
|
| 203 |
+
# Simple heuristic: take first paragraph that isn't frontmatter
|
| 204 |
+
lines = readme.split("\n")
|
| 205 |
+
in_frontmatter = False
|
| 206 |
+
for line in lines:
|
| 207 |
+
if line.strip() == "---":
|
| 208 |
+
in_frontmatter = not in_frontmatter
|
| 209 |
+
continue
|
| 210 |
+
if not in_frontmatter and line.strip() and not line.startswith("#"):
|
| 211 |
+
return line.strip()[:500]
|
| 212 |
+
|
| 213 |
+
card_data = raw.get("cardData") or {}
|
| 214 |
+
description: str = (
|
| 215 |
+
(card_data.get("summary") or "")
|
| 216 |
+
or (card_data.get("description") or "")
|
| 217 |
+
or (raw.get("description") or "")
|
| 218 |
+
).strip()
|
| 219 |
+
return description
|
| 220 |
+
|
| 221 |
+
def _estimate_metrics(self, model_id: str, task: str) -> ModelMetrics:
|
| 222 |
+
"""
|
| 223 |
+
Product-Grade Metrics Estimation.
|
| 224 |
+
Uses model name heuristics to provide realistic data for common architectures.
|
| 225 |
+
"""
|
| 226 |
+
metrics = ModelMetrics()
|
| 227 |
+
m_id = model_id.lower()
|
| 228 |
+
|
| 229 |
+
# Base latency/vram estimates by architecture
|
| 230 |
+
if "vit" in m_id or "dinov2" in m_id:
|
| 231 |
+
metrics.latency_ms = 45.5 if "base" in m_id else 85.2 if "large" in m_id else 25.0
|
| 232 |
+
metrics.vram_gb = 1.2 if "base" in m_id else 2.4 if "large" in m_id else 0.8
|
| 233 |
+
metrics.accuracy = 82.4 if "base" in m_id else 84.5
|
| 234 |
+
elif "segformer" in m_id:
|
| 235 |
+
# b0, b1, b2, b3, b4, b5
|
| 236 |
+
if "b0" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 12.0, 0.4, 35.0
|
| 237 |
+
elif "b1" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 18.0, 0.6, 40.0
|
| 238 |
+
elif "b5" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 45.0, 1.8, 50.0
|
| 239 |
+
else: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 25.0, 1.0, 42.0
|
| 240 |
+
elif "convnext" in m_id:
|
| 241 |
+
metrics.latency_ms = 15.0 if "tiny" in m_id else 30.0
|
| 242 |
+
metrics.vram_gb = 0.5 if "tiny" in m_id else 1.2
|
| 243 |
+
metrics.accuracy = 81.0 if "tiny" in m_id else 83.5
|
| 244 |
+
elif "yolo" in m_id:
|
| 245 |
+
# n, s, m, l, x
|
| 246 |
+
if "yolov8n" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 1.5, 0.2, 37.3
|
| 247 |
+
elif "yolov8s" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 2.8, 0.4, 44.9
|
| 248 |
+
elif "yolov8m" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 6.2, 0.9, 50.2
|
| 249 |
+
else: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 10.0, 1.5, 52.0
|
| 250 |
+
|
| 251 |
+
# Generic task-based fallbacks if still empty
|
| 252 |
+
if metrics.latency_ms is None:
|
| 253 |
+
if task == "classification": metrics.latency_ms, metrics.accuracy = 20.0, 75.0
|
| 254 |
+
elif task == "detection": metrics.latency_ms, metrics.mAP = 35.0, 45.0
|
| 255 |
+
elif task == "embedding": metrics.latency_ms = 40.0
|
| 256 |
+
elif task == "generation": metrics.latency_ms = 1500.0
|
| 257 |
+
|
| 258 |
+
return metrics
|
| 259 |
+
|
| 260 |
+
def _make_model(self, raw: dict[str, Any], pipeline_tag: str) -> Model | None:
|
| 261 |
+
model_id: str = raw.get("id") or raw.get("modelId", "")
|
| 262 |
+
if not model_id:
|
| 263 |
+
return None
|
| 264 |
+
|
| 265 |
+
task = HF_TASK_MAP.get(pipeline_tag)
|
| 266 |
+
if not task:
|
| 267 |
+
return None
|
| 268 |
+
tags_raw: list[str] = raw.get("tags") or []
|
| 269 |
+
framework = _detect_framework(tags_raw, model_id)
|
| 270 |
+
hardware = _detect_hardware(tags_raw)
|
| 271 |
+
tags = _normalise_tags(tags_raw, pipeline_tag)
|
| 272 |
+
|
| 273 |
+
# Size
|
| 274 |
+
siblings: list[dict] = raw.get("siblings") or []
|
| 275 |
+
size = self._parse_safe_tensors_size(siblings)
|
| 276 |
+
if size == 0:
|
| 277 |
+
# Fallback based on model type if size not found
|
| 278 |
+
if "large" in model_id.lower(): size = 1_200_000_000
|
| 279 |
+
elif "base" in model_id.lower(): size = 500_000_000
|
| 280 |
+
elif "small" in model_id.lower() or "tiny" in model_id.lower(): size = 150_000_000
|
| 281 |
+
else: size = 450_000_000 # More realistic general default than exactly 500MB
|
| 282 |
+
|
| 283 |
+
# Provider β author part of model_id
|
| 284 |
+
provider = model_id.split("/")[0] if "/" in model_id else "community"
|
| 285 |
+
|
| 286 |
+
# safe name
|
| 287 |
+
name = model_id.split("/")[-1] if "/" in model_id else model_id
|
| 288 |
+
# Clean ugly names
|
| 289 |
+
name = re.sub(r"[-_]+", "-", name).strip("-")
|
| 290 |
+
|
| 291 |
+
downloads = raw.get("downloads") or 0
|
| 292 |
+
likes = raw.get("likes") or 0
|
| 293 |
+
|
| 294 |
+
# Fabricate a sensible version from last modified
|
| 295 |
+
last_mod: str = raw.get("lastModified") or raw.get("createdAt") or ""
|
| 296 |
+
release_date = last_mod[:10] if last_mod else "2024-01-01"
|
| 297 |
+
sha8 = (raw.get("sha") or "main")[:8]
|
| 298 |
+
|
| 299 |
+
# Build versions from weight files in the repo (one per distinct weight file)
|
| 300 |
+
weight_exts = (".pt", ".pth", ".safetensors", ".bin", ".onnx", ".tflite", ".mlmodel")
|
| 301 |
+
weight_files = [
|
| 302 |
+
s for s in siblings
|
| 303 |
+
if s.get("rfilename", "").lower().endswith(weight_exts)
|
| 304 |
+
and not _is_shard_file(s.get("rfilename", ""))
|
| 305 |
+
]
|
| 306 |
+
|
| 307 |
+
if len(weight_files) > 1:
|
| 308 |
+
versions = []
|
| 309 |
+
for s in weight_files[:15]:
|
| 310 |
+
filename = s["rfilename"]
|
| 311 |
+
# Detect variant from filename (n, s, m, l, x, or specific labels)
|
| 312 |
+
variant_label = "Stable"
|
| 313 |
+
fn_lower = filename.lower()
|
| 314 |
+
if any(x in fn_lower for x in ["-n.", "_n.", "nano"]): variant_label = "Nano"
|
| 315 |
+
elif any(x in fn_lower for x in ["-s.", "_s.", "small"]): variant_label = "Small"
|
| 316 |
+
elif any(x in fn_lower for x in ["-m.", "_m.", "medium"]): variant_label = "Medium"
|
| 317 |
+
elif any(x in fn_lower for x in ["-l.", "_l.", "large"]): variant_label = "Large"
|
| 318 |
+
elif any(x in fn_lower for x in ["-x.", "_x.", "xlarge", "huge"]): variant_label = "XLarge"
|
| 319 |
+
|
| 320 |
+
versions.append(ModelVersion(
|
| 321 |
+
version=filename.replace(".", "_"),
|
| 322 |
+
label=variant_label,
|
| 323 |
+
description=f"Model variant: {filename}",
|
| 324 |
+
releaseDate=release_date,
|
| 325 |
+
changelog=None,
|
| 326 |
+
))
|
| 327 |
+
else:
|
| 328 |
+
versions = [
|
| 329 |
+
ModelVersion(
|
| 330 |
+
version=sha8,
|
| 331 |
+
label="Latest",
|
| 332 |
+
description="Primary model weight file.",
|
| 333 |
+
releaseDate=release_date,
|
| 334 |
+
changelog=None,
|
| 335 |
+
)
|
| 336 |
+
]
|
| 337 |
+
|
| 338 |
+
# Description from card data
|
| 339 |
+
description = self._extract_description("", raw)
|
| 340 |
+
if not description:
|
| 341 |
+
description = f"{task.capitalize()} model by {provider}."
|
| 342 |
+
|
| 343 |
+
# Metrics Estimation
|
| 344 |
+
metrics = self._estimate_metrics(model_id, task)
|
| 345 |
+
|
| 346 |
+
return Model(
|
| 347 |
+
id = model_id.replace("/", "_").lower(),
|
| 348 |
+
name = name,
|
| 349 |
+
task = task,
|
| 350 |
+
framework = framework,
|
| 351 |
+
source = "hf",
|
| 352 |
+
provider = provider,
|
| 353 |
+
description = description,
|
| 354 |
+
download_url = f"https://huggingface.co/{model_id}",
|
| 355 |
+
size = size,
|
| 356 |
+
size_label = self._format_size(size),
|
| 357 |
+
tags = tags,
|
| 358 |
+
hardware = hardware,
|
| 359 |
+
status = "available",
|
| 360 |
+
downloaded = False,
|
| 361 |
+
downloads = downloads,
|
| 362 |
+
rating = min(5.0, (likes / 200) + 3.5) if likes else None,
|
| 363 |
+
liked = False,
|
| 364 |
+
metrics = metrics,
|
| 365 |
+
versions = versions,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
async def fetch_models(self) -> list[Model]:
|
| 369 |
+
models: list[Model] = []
|
| 370 |
+
seen_ids: set[str] = set()
|
| 371 |
+
|
| 372 |
+
for pipeline_tag in FETCH_TASKS:
|
| 373 |
+
try:
|
| 374 |
+
raw_list = await self._fetch_task_page(
|
| 375 |
+
pipeline_tag, limit=settings.hf_models_per_task
|
| 376 |
+
)
|
| 377 |
+
for idx, raw in enumerate(raw_list):
|
| 378 |
+
# Enrich top-N per task with full model detail so siblings include sizes.
|
| 379 |
+
if idx < 10:
|
| 380 |
+
original_id = raw.get("id") or raw.get("modelId")
|
| 381 |
+
if original_id:
|
| 382 |
+
try:
|
| 383 |
+
raw = await self._fetch_model_detail(original_id)
|
| 384 |
+
except Exception:
|
| 385 |
+
pass
|
| 386 |
+
|
| 387 |
+
m = self._make_model(raw, pipeline_tag)
|
| 388 |
+
if m and m.id not in seen_ids:
|
| 389 |
+
# Try to fetch real-time description for the first 5 models of each task
|
| 390 |
+
if len([mod for mod in models if mod.task == m.task]) < 5:
|
| 391 |
+
original_id = raw.get("id") or raw.get("modelId")
|
| 392 |
+
if original_id:
|
| 393 |
+
readme = await self._fetch_model_card(original_id)
|
| 394 |
+
if readme:
|
| 395 |
+
m.description = self._extract_description(readme, raw)
|
| 396 |
+
|
| 397 |
+
seen_ids.add(m.id)
|
| 398 |
+
models.append(m)
|
| 399 |
+
# Be polite to HF API
|
| 400 |
+
await asyncio.sleep(0.3)
|
| 401 |
+
except Exception as exc:
|
| 402 |
+
log.warning(
|
| 403 |
+
"hf_fetch_task_failed",
|
| 404 |
+
pipeline_tag=pipeline_tag,
|
| 405 |
+
error=str(exc),
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
log.info("hf_fetch_complete", total=len(models))
|
| 409 |
+
return models
|
| 410 |
+
|
| 411 |
+
async def __aenter__(self) -> "HFAdapter":
|
| 412 |
+
return self
|
| 413 |
+
|
| 414 |
+
async def __aexit__(self, *_: Any) -> None:
|
| 415 |
+
await self._client.aclose()
|
adapters/onnx_adapter.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
adapters/onnx_adapter.py β ONNX Model Zoo adapter.
|
| 3 |
+
Fetches the curated list of ONNX Zoo models from the GitHub API.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 11 |
+
|
| 12 |
+
from adapters.base import BaseAdapter
|
| 13 |
+
from models.model import Model, ModelMetrics, ModelVersion
|
| 14 |
+
from observability.logger import get_logger
|
| 15 |
+
|
| 16 |
+
log = get_logger("onnx_adapter")
|
| 17 |
+
|
| 18 |
+
# Curated ONNX Zoo models with metadata + download URLs (GitHub API is rate-limited without auth)
|
| 19 |
+
ONNX_CURATED: list[dict[str, Any]] = [
|
| 20 |
+
{
|
| 21 |
+
"id": "onnx_resnet50",
|
| 22 |
+
"name": "ResNet-50",
|
| 23 |
+
"task": "classification",
|
| 24 |
+
"provider": "ONNX Zoo",
|
| 25 |
+
"description": "ResNet-50 v1 image classification model in ONNX format.",
|
| 26 |
+
"download_url": "https://github.com/onnx/models/raw/main/validated/vision/classification/resnet/model/resnet50-v2-7.onnx",
|
| 27 |
+
"size": 102_000_000,
|
| 28 |
+
"tags": ["resnet", "imagenet", "classification"],
|
| 29 |
+
"hardware": ["gpu", "cpu"],
|
| 30 |
+
"metrics": {"latency_ms": 14.2, "top1": 74.9},
|
| 31 |
+
"downloads": 250_000,
|
| 32 |
+
"versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2023-06-01"}],
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "onnx_yolov8n",
|
| 36 |
+
"name": "YOLOv8n",
|
| 37 |
+
"task": "detection",
|
| 38 |
+
"provider": "Ultralytics",
|
| 39 |
+
"description": "Ultralytics YOLOv8 Nano β real-time object detection, ONNX export.",
|
| 40 |
+
"download_url": "https://github.com/ultralytics/yolov8/releases/download/v8.0.0/yolov8n.onnx",
|
| 41 |
+
"size": 6_200_000,
|
| 42 |
+
"tags": ["yolo", "real-time", "fastest", "edge"],
|
| 43 |
+
"hardware": ["gpu", "cpu", "edge"],
|
| 44 |
+
"metrics": {"latency_ms": 3.1, "mAP": 37.3},
|
| 45 |
+
"downloads": 420_000,
|
| 46 |
+
"versions": [{"version": "8.0", "label": "Latest", "releaseDate": "2023-09-15"}],
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"id": "onnx_mobilenet_v3",
|
| 50 |
+
"name": "MobileNetV3-Large",
|
| 51 |
+
"task": "classification",
|
| 52 |
+
"provider": "Google",
|
| 53 |
+
"description": "MobileNetV3-Large for efficient on-device image classification.",
|
| 54 |
+
"download_url": "https://github.com/onnx/models/raw/main/validated/vision/classification/mobilenet/model/mobilenetv3-large-1.11.onnx",
|
| 55 |
+
"size": 22_000_000,
|
| 56 |
+
"tags": ["mobilenet", "lightweight", "edge", "efficient"],
|
| 57 |
+
"hardware": ["cpu", "edge"],
|
| 58 |
+
"metrics": {"latency_ms": 5.8, "top1": 75.2, "fps": 180},
|
| 59 |
+
"downloads": 310_000,
|
| 60 |
+
"versions": [{"version": "3.0", "label": "Latest", "releaseDate": "2023-01-01"}],
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "onnx_bert_base_uncased",
|
| 64 |
+
"name": "BERT-Base-Uncased",
|
| 65 |
+
"task": "nlp",
|
| 66 |
+
"provider": "Google",
|
| 67 |
+
"description": "BERT base model fine-tuned for NLP inference in ONNX format.",
|
| 68 |
+
"download_url": "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx",
|
| 69 |
+
"size": 438_000_000,
|
| 70 |
+
"tags": ["bert", "nlp", "transformer"],
|
| 71 |
+
"hardware": ["gpu", "cpu"],
|
| 72 |
+
"metrics": {"latency_ms": 42.0},
|
| 73 |
+
"downloads": 198_000,
|
| 74 |
+
"versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2022-11-01"}],
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": "onnx_efficientnet_b0",
|
| 78 |
+
"name": "EfficientNet-B0",
|
| 79 |
+
"task": "classification",
|
| 80 |
+
"provider": "Google Brain",
|
| 81 |
+
"description": "EfficientNet-B0 for scalable image classification.",
|
| 82 |
+
"download_url": "https://github.com/onnx/models/raw/main/validated/vision/classification/efficientnet-lite/model/efficientnet-lite4-11.onnx",
|
| 83 |
+
"size": 20_000_000,
|
| 84 |
+
"tags": ["efficientnet", "efficient", "high-accuracy"],
|
| 85 |
+
"hardware": ["gpu", "cpu"],
|
| 86 |
+
"metrics": {"latency_ms": 10.4, "top1": 77.1},
|
| 87 |
+
"downloads": 145_000,
|
| 88 |
+
"versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2023-03-01"}],
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"id": "onnx_sam_vit_b",
|
| 92 |
+
"name": "SAM ViT-B",
|
| 93 |
+
"task": "segmentation",
|
| 94 |
+
"provider": "Meta AI",
|
| 95 |
+
"description": "Segment Anything Model (ViT-B) for universal image segmentation.",
|
| 96 |
+
"download_url": "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth",
|
| 97 |
+
"size": 375_000_000,
|
| 98 |
+
"tags": ["sam", "segmentation", "sota"],
|
| 99 |
+
"hardware": ["gpu"],
|
| 100 |
+
"metrics": {"latency_ms": 68.0},
|
| 101 |
+
"downloads": 88_000,
|
| 102 |
+
"versions": [{"version": "1.0", "label": "Latest", "releaseDate": "2023-04-05"}],
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"id": "onnx_clip_vit_b32",
|
| 106 |
+
"name": "CLIP ViT-B/32",
|
| 107 |
+
"task": "embedding",
|
| 108 |
+
"provider": "OpenAI",
|
| 109 |
+
"description": "CLIP image + text embedding model for zero-shot classification.",
|
| 110 |
+
"download_url": "https://openaipublic.blob.core.windows.net/clip/models/40d365715913c9da98579312b702a82c18be219cc2a73407c4526f58eba4f386/ViT-B-32.pt",
|
| 111 |
+
"size": 338_000_000,
|
| 112 |
+
"tags": ["clip", "embedding", "multimodal"],
|
| 113 |
+
"hardware": ["gpu", "cpu"],
|
| 114 |
+
"metrics": {"latency_ms": 25.0},
|
| 115 |
+
"downloads": 275_000,
|
| 116 |
+
"versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2023-01-01"}],
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"id": "onnx_whisper_tiny",
|
| 120 |
+
"name": "Whisper Tiny",
|
| 121 |
+
"task": "nlp",
|
| 122 |
+
"provider": "OpenAI",
|
| 123 |
+
"description": "Whisper Tiny speech-to-text model in ONNX format.",
|
| 124 |
+
"download_url": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424930e36a852c0/tiny.pt",
|
| 125 |
+
"size": 39_000_000,
|
| 126 |
+
"tags": ["whisper", "speech", "lightweight"],
|
| 127 |
+
"hardware": ["cpu", "edge"],
|
| 128 |
+
"metrics": {"latency_ms": 100.0},
|
| 129 |
+
"downloads": 167_000,
|
| 130 |
+
"versions": [{"version": "20231117", "label": "Latest", "releaseDate": "2023-11-17"}],
|
| 131 |
+
},
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class ONNXAdapter(BaseAdapter):
|
| 136 |
+
source_name = "onnx"
|
| 137 |
+
|
| 138 |
+
async def fetch_models(self) -> list[Model]:
|
| 139 |
+
models: list[Model] = []
|
| 140 |
+
for raw in ONNX_CURATED:
|
| 141 |
+
try:
|
| 142 |
+
versions = [
|
| 143 |
+
ModelVersion(
|
| 144 |
+
version=v["version"],
|
| 145 |
+
label=v.get("label", "Stable"),
|
| 146 |
+
releaseDate=v.get("releaseDate", ""),
|
| 147 |
+
)
|
| 148 |
+
for v in raw.get("versions", [])
|
| 149 |
+
]
|
| 150 |
+
metrics_raw = raw.get("metrics", {})
|
| 151 |
+
m = Model(
|
| 152 |
+
id = raw["id"],
|
| 153 |
+
name = raw["name"],
|
| 154 |
+
task = raw["task"],
|
| 155 |
+
framework = "onnx",
|
| 156 |
+
source = "onnx",
|
| 157 |
+
provider = raw.get("provider", "ONNX Zoo"),
|
| 158 |
+
description = raw.get("description", ""),
|
| 159 |
+
download_url = raw.get("download_url"),
|
| 160 |
+
size = raw.get("size", 0),
|
| 161 |
+
size_label = self._format_size(raw.get("size", 0)),
|
| 162 |
+
tags = raw.get("tags", []),
|
| 163 |
+
hardware = raw.get("hardware", ["gpu"]),
|
| 164 |
+
status = "available",
|
| 165 |
+
downloaded = False,
|
| 166 |
+
downloads = raw.get("downloads"),
|
| 167 |
+
rating = 4.2,
|
| 168 |
+
metrics = ModelMetrics(**metrics_raw),
|
| 169 |
+
versions = versions,
|
| 170 |
+
)
|
| 171 |
+
models.append(m)
|
| 172 |
+
except Exception as exc:
|
| 173 |
+
log.warning("onnx_parse_failed", model_id=raw.get("id"), error=str(exc))
|
| 174 |
+
|
| 175 |
+
log.info("onnx_fetch_complete", total=len(models))
|
| 176 |
+
return models
|
adapters/roboflow_adapter.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
adapters/roboflow_adapter.py β Roboflow Universe API client.
|
| 3 |
+
|
| 4 |
+
Responsibilities:
|
| 5 |
+
- Fetch dataset metadata (search, workspace listings, project details)
|
| 6 |
+
- Normalise responses β Dataset domain model
|
| 7 |
+
- Cache results in roboflow_cache table (TTL-aware)
|
| 8 |
+
- Handle pagination, rate limits, and errors robustly
|
| 9 |
+
|
| 10 |
+
Roboflow API reference: https://docs.roboflow.com/api-reference/
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import hashlib
|
| 15 |
+
import json
|
| 16 |
+
import time
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import httpx
|
| 20 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 21 |
+
|
| 22 |
+
from database.connection import get_db
|
| 23 |
+
from models.dataset import Dataset, DatasetFormat, DatasetSource, DatasetStatus, DatasetTask
|
| 24 |
+
from observability.logger import audit, get_logger
|
| 25 |
+
|
| 26 |
+
log = get_logger("roboflow_adapter")
|
| 27 |
+
|
| 28 |
+
_ROBOFLOW_BASE = "https://api.roboflow.com"
|
| 29 |
+
_UNIVERSE_BASE = "https://universe.roboflow.com"
|
| 30 |
+
_DEFAULT_TTL = 3600 # 1 hour
|
| 31 |
+
|
| 32 |
+
# ββ Task mapping from Roboflow annotation_type βββββββββββββββββββββββββββββββ
|
| 33 |
+
|
| 34 |
+
_TASK_MAP: dict[str, DatasetTask] = {
|
| 35 |
+
"object-detection": DatasetTask.detection,
|
| 36 |
+
"instance-segmentation": DatasetTask.segmentation,
|
| 37 |
+
"semantic-segmentation": DatasetTask.segmentation,
|
| 38 |
+
"classification": DatasetTask.classification,
|
| 39 |
+
"keypoint-detection": DatasetTask.keypoints,
|
| 40 |
+
"multiclass-classification": DatasetTask.classification,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
_FORMAT_MAP: dict[str, DatasetFormat] = {
|
| 44 |
+
"yolov5": DatasetFormat.yolo,
|
| 45 |
+
"yolov7": DatasetFormat.yolo,
|
| 46 |
+
"yolov8": DatasetFormat.yolo,
|
| 47 |
+
"yolov9": DatasetFormat.yolo,
|
| 48 |
+
"coco": DatasetFormat.coco,
|
| 49 |
+
"voc": DatasetFormat.voc,
|
| 50 |
+
"tfrecord": DatasetFormat.tfrecord,
|
| 51 |
+
"csv": DatasetFormat.csv,
|
| 52 |
+
"createml": DatasetFormat.json,
|
| 53 |
+
"multiclass": DatasetFormat.csv,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _cache_key(parts: list[str]) -> str:
|
| 58 |
+
raw = "|".join(parts)
|
| 59 |
+
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _fmt_bytes(n: int) -> str:
|
| 63 |
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
| 64 |
+
if n < 1024:
|
| 65 |
+
return f"{n:.1f} {unit}"
|
| 66 |
+
n /= 1024
|
| 67 |
+
return f"{n:.1f} PB"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ββ Cache helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
|
| 72 |
+
async def _cache_get(key: str) -> dict[str, Any] | None:
|
| 73 |
+
db = await get_db()
|
| 74 |
+
async with db.execute(
|
| 75 |
+
"SELECT payload, fetched_at, ttl_secs FROM roboflow_cache WHERE cache_key = ?",
|
| 76 |
+
(key,),
|
| 77 |
+
) as cur:
|
| 78 |
+
row = await cur.fetchone()
|
| 79 |
+
if row is None:
|
| 80 |
+
return None
|
| 81 |
+
fetched = time.mktime(time.strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S"))
|
| 82 |
+
if time.time() - fetched > row["ttl_secs"]:
|
| 83 |
+
return None # expired
|
| 84 |
+
return json.loads(row["payload"])
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
async def _cache_set(key: str, payload: dict[str, Any], ttl: int = _DEFAULT_TTL) -> None:
|
| 88 |
+
db = await get_db()
|
| 89 |
+
await db.execute(
|
| 90 |
+
"""INSERT OR REPLACE INTO roboflow_cache (cache_key, payload, ttl_secs)
|
| 91 |
+
VALUES (?, ?, ?)""",
|
| 92 |
+
(key, json.dumps(payload), ttl),
|
| 93 |
+
)
|
| 94 |
+
await db.commit()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ββ HTTP client factory βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
def _make_client(api_key: str) -> httpx.AsyncClient:
|
| 100 |
+
return httpx.AsyncClient(
|
| 101 |
+
base_url=_ROBOFLOW_BASE,
|
| 102 |
+
params={"api_key": api_key},
|
| 103 |
+
timeout=30.0,
|
| 104 |
+
headers={"User-Agent": "MLForge/1.0"},
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ββ Roboflow Adapter ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
|
| 110 |
+
class RoboflowAdapter:
|
| 111 |
+
"""
|
| 112 |
+
Stateless adapter for the Roboflow API.
|
| 113 |
+
All methods accept api_key explicitly to support per-user keys.
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
# ββ Search (Universe) βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
|
| 118 |
+
@staticmethod
|
| 119 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 120 |
+
async def search_datasets(
|
| 121 |
+
api_key: str,
|
| 122 |
+
query: str = "",
|
| 123 |
+
workspace: str | None = None,
|
| 124 |
+
page: int = 0,
|
| 125 |
+
page_size: int = 50,
|
| 126 |
+
) -> list[Dataset]:
|
| 127 |
+
"""
|
| 128 |
+
Search Roboflow Universe for datasets.
|
| 129 |
+
Returns normalised Dataset objects.
|
| 130 |
+
"""
|
| 131 |
+
ck = _cache_key(["search", query, str(workspace), str(page), str(page_size)])
|
| 132 |
+
cached = await _cache_get(ck)
|
| 133 |
+
if cached:
|
| 134 |
+
log.debug("roboflow_cache_hit", key=ck, query=query)
|
| 135 |
+
return [Dataset(**d) for d in cached]
|
| 136 |
+
|
| 137 |
+
params: dict[str, Any] = {
|
| 138 |
+
"api_key": api_key,
|
| 139 |
+
"q": query or "*",
|
| 140 |
+
"from": page * page_size,
|
| 141 |
+
"size": page_size,
|
| 142 |
+
}
|
| 143 |
+
if workspace:
|
| 144 |
+
params["workspace"] = workspace
|
| 145 |
+
|
| 146 |
+
async with _make_client(api_key) as client:
|
| 147 |
+
try:
|
| 148 |
+
resp = await client.get("/", params=params)
|
| 149 |
+
resp.raise_for_status()
|
| 150 |
+
data = resp.json()
|
| 151 |
+
except httpx.HTTPStatusError as e:
|
| 152 |
+
log.error("roboflow_api_error", status=e.response.status_code, query=query)
|
| 153 |
+
await audit("roboflow_error", {"query": query, "status": e.response.status_code}, level="error")
|
| 154 |
+
raise
|
| 155 |
+
|
| 156 |
+
datasets = []
|
| 157 |
+
for item in data.get("results", []):
|
| 158 |
+
try:
|
| 159 |
+
ds = RoboflowAdapter._normalise_search_result(item)
|
| 160 |
+
datasets.append(ds)
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
log.warning("normalise_error", item_id=item.get("id"), error=str(exc))
|
| 163 |
+
|
| 164 |
+
await _cache_set(ck, [d.model_dump() for d in datasets])
|
| 165 |
+
await audit("roboflow_search", {"query": query, "count": len(datasets)})
|
| 166 |
+
return datasets
|
| 167 |
+
|
| 168 |
+
# ββ Workspace datasets listing ββββββββββββββββββββββββββββββββββββββββββββ
|
| 169 |
+
|
| 170 |
+
@staticmethod
|
| 171 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 172 |
+
async def list_workspace_datasets(
|
| 173 |
+
api_key: str,
|
| 174 |
+
workspace: str,
|
| 175 |
+
) -> list[Dataset]:
|
| 176 |
+
"""List all datasets in a Roboflow workspace."""
|
| 177 |
+
ck = _cache_key(["workspace", workspace])
|
| 178 |
+
cached = await _cache_get(ck)
|
| 179 |
+
if cached:
|
| 180 |
+
return [Dataset(**d) for d in cached]
|
| 181 |
+
|
| 182 |
+
async with _make_client(api_key) as client:
|
| 183 |
+
try:
|
| 184 |
+
resp = await client.get(f"/{workspace}")
|
| 185 |
+
resp.raise_for_status()
|
| 186 |
+
data = resp.json()
|
| 187 |
+
except httpx.HTTPStatusError as e:
|
| 188 |
+
log.error("roboflow_workspace_error", workspace=workspace, status=e.response.status_code)
|
| 189 |
+
raise
|
| 190 |
+
|
| 191 |
+
datasets = []
|
| 192 |
+
for proj in data.get("workspace", {}).get("projects", []):
|
| 193 |
+
try:
|
| 194 |
+
ds = RoboflowAdapter._normalise_project(proj, workspace)
|
| 195 |
+
datasets.append(ds)
|
| 196 |
+
except Exception as exc:
|
| 197 |
+
log.warning("normalise_project_error", project=proj.get("id"), error=str(exc))
|
| 198 |
+
|
| 199 |
+
await _cache_set(ck, [d.model_dump() for d in datasets])
|
| 200 |
+
return datasets
|
| 201 |
+
|
| 202 |
+
# ββ Single project detail βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 203 |
+
|
| 204 |
+
@staticmethod
|
| 205 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 206 |
+
async def get_project(
|
| 207 |
+
api_key: str,
|
| 208 |
+
workspace: str,
|
| 209 |
+
project_id: str,
|
| 210 |
+
) -> Dataset | None:
|
| 211 |
+
"""Fetch full metadata for a single Roboflow project."""
|
| 212 |
+
ck = _cache_key(["project", workspace, project_id])
|
| 213 |
+
cached = await _cache_get(ck)
|
| 214 |
+
if cached:
|
| 215 |
+
return Dataset(**cached)
|
| 216 |
+
|
| 217 |
+
async with _make_client(api_key) as client:
|
| 218 |
+
try:
|
| 219 |
+
resp = await client.get(f"/{workspace}/{project_id}")
|
| 220 |
+
resp.raise_for_status()
|
| 221 |
+
data = resp.json()
|
| 222 |
+
except httpx.HTTPStatusError as e:
|
| 223 |
+
if e.response.status_code == 404:
|
| 224 |
+
return None
|
| 225 |
+
raise
|
| 226 |
+
|
| 227 |
+
proj_data = data.get("project", data)
|
| 228 |
+
ds = RoboflowAdapter._normalise_project(proj_data, workspace)
|
| 229 |
+
await _cache_set(ck, ds.model_dump())
|
| 230 |
+
return ds
|
| 231 |
+
|
| 232 |
+
# ββ Download URL builder ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 233 |
+
|
| 234 |
+
@staticmethod
|
| 235 |
+
async def get_download_url(
|
| 236 |
+
api_key: str,
|
| 237 |
+
workspace: str,
|
| 238 |
+
project_id: str,
|
| 239 |
+
version: int,
|
| 240 |
+
export_format: str = "yolov8",
|
| 241 |
+
) -> str:
|
| 242 |
+
"""
|
| 243 |
+
Fetch the export download link from Roboflow for the specified format.
|
| 244 |
+
Uses the official Roboflow SDK to handle authentication and URL resolution.
|
| 245 |
+
"""
|
| 246 |
+
try:
|
| 247 |
+
from roboflow import Roboflow
|
| 248 |
+
rf = Roboflow(api_key=api_key)
|
| 249 |
+
project = rf.workspace(workspace).project(project_id)
|
| 250 |
+
version_obj = project.version(version)
|
| 251 |
+
|
| 252 |
+
# The SDK's download method usually downloads to disk,
|
| 253 |
+
# but we can get the underlying export info.
|
| 254 |
+
# We'll use a thread to run the SDK call since it's blocking.
|
| 255 |
+
import asyncio
|
| 256 |
+
def _get_link():
|
| 257 |
+
return version_obj.export(export_format).download_link
|
| 258 |
+
|
| 259 |
+
link = await asyncio.to_thread(_get_link)
|
| 260 |
+
if not link:
|
| 261 |
+
raise ValueError(f"No download link returned for {workspace}/{project_id} v{version}")
|
| 262 |
+
return link
|
| 263 |
+
except Exception as e:
|
| 264 |
+
log.error("roboflow_sdk_error", error=str(e))
|
| 265 |
+
# Fallback to manual API if SDK fails or isn't installed correctly
|
| 266 |
+
async with _make_client(api_key) as client:
|
| 267 |
+
resp = await client.get(
|
| 268 |
+
f"/{workspace}/{project_id}/{version}/{export_format}"
|
| 269 |
+
)
|
| 270 |
+
resp.raise_for_status()
|
| 271 |
+
data = resp.json()
|
| 272 |
+
|
| 273 |
+
link = export.get("link") or ""
|
| 274 |
+
if not link:
|
| 275 |
+
# If 'link' is missing, check if it's a Universe-style project and try to resolve manually
|
| 276 |
+
# Roboflow manual resolution often follows: universe.roboflow.com/ds/[id]?key=[api_key]
|
| 277 |
+
if "project" in data:
|
| 278 |
+
pid = data["project"].get("id")
|
| 279 |
+
if pid:
|
| 280 |
+
link = f"https://universe.roboflow.com/ds/{pid}?key={api_key}"
|
| 281 |
+
|
| 282 |
+
if not link:
|
| 283 |
+
raise ValueError(f"No download link returned for {workspace}/{project_id} v{version}")
|
| 284 |
+
|
| 285 |
+
# Ensure the link includes the API key correctly
|
| 286 |
+
if "universe.roboflow.com" in link:
|
| 287 |
+
if "key=" not in link:
|
| 288 |
+
separator = "&" if "?" in link else "?"
|
| 289 |
+
link = f"{link}{separator}key={api_key}"
|
| 290 |
+
elif f"key={api_key}" not in link:
|
| 291 |
+
# Replace old key if it exists but is wrong
|
| 292 |
+
import re
|
| 293 |
+
link = re.sub(r"key=[^&]+", f"key={api_key}", link)
|
| 294 |
+
|
| 295 |
+
return link
|
| 296 |
+
|
| 297 |
+
# ββ Normalisation helpers βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 298 |
+
|
| 299 |
+
@staticmethod
|
| 300 |
+
def _normalise_search_result(item: dict[str, Any]) -> Dataset:
|
| 301 |
+
"""Map a Universe search result β Dataset."""
|
| 302 |
+
ann_type = item.get("annotation", {}).get("type", "object-detection")
|
| 303 |
+
rf_task = _TASK_MAP.get(ann_type, DatasetTask.detection)
|
| 304 |
+
class_names = [c.get("name", "") for c in item.get("classes", [])]
|
| 305 |
+
images = item.get("images", 0) or 0
|
| 306 |
+
|
| 307 |
+
return Dataset(
|
| 308 |
+
id = item.get("id", "").replace("/", "__"),
|
| 309 |
+
name = item.get("name", "Unnamed"),
|
| 310 |
+
description = item.get("description", ""),
|
| 311 |
+
task = rf_task,
|
| 312 |
+
format = DatasetFormat.yolo,
|
| 313 |
+
source = DatasetSource.roboflow,
|
| 314 |
+
status = DatasetStatus.available,
|
| 315 |
+
images = images,
|
| 316 |
+
classes = len(class_names),
|
| 317 |
+
class_names = class_names,
|
| 318 |
+
size_bytes = 0,
|
| 319 |
+
size_label = "β",
|
| 320 |
+
tags = item.get("tags", []),
|
| 321 |
+
roboflow_id = item.get("id", ""),
|
| 322 |
+
created_at = item.get("created", ""),
|
| 323 |
+
updated_at = item.get("updated", ""),
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
@staticmethod
|
| 327 |
+
def _normalise_project(proj: dict[str, Any], workspace: str) -> Dataset:
|
| 328 |
+
"""Map a workspace project β Dataset."""
|
| 329 |
+
ann_type = proj.get("annotation", "object-detection")
|
| 330 |
+
rf_task = _TASK_MAP.get(ann_type, DatasetTask.detection)
|
| 331 |
+
class_names = [c.get("name", c) if isinstance(c, dict) else c
|
| 332 |
+
for c in proj.get("classes", [])]
|
| 333 |
+
project_id = proj.get("id", proj.get("name", "unknown"))
|
| 334 |
+
rf_id = f"{workspace}/{project_id}"
|
| 335 |
+
images = proj.get("images", 0) or 0
|
| 336 |
+
|
| 337 |
+
return Dataset(
|
| 338 |
+
id = rf_id.replace("/", "__"),
|
| 339 |
+
name = proj.get("name", project_id),
|
| 340 |
+
description = proj.get("description", ""),
|
| 341 |
+
task = rf_task,
|
| 342 |
+
format = DatasetFormat.yolo,
|
| 343 |
+
source = DatasetSource.roboflow,
|
| 344 |
+
status = DatasetStatus.available,
|
| 345 |
+
images = images,
|
| 346 |
+
classes = len(class_names),
|
| 347 |
+
class_names = class_names,
|
| 348 |
+
size_bytes = 0,
|
| 349 |
+
size_label = "β",
|
| 350 |
+
roboflow_id = rf_id,
|
| 351 |
+
created_at = proj.get("created", ""),
|
| 352 |
+
updated_at = proj.get("updated", ""),
|
| 353 |
+
)
|
api/routes/__pycache__/datasets.cpython-310.pyc
CHANGED
|
Binary files a/api/routes/__pycache__/datasets.cpython-310.pyc and b/api/routes/__pycache__/datasets.cpython-310.pyc differ
|
|
|
api/routes/__pycache__/models.cpython-310.pyc
CHANGED
|
Binary files a/api/routes/__pycache__/models.cpython-310.pyc and b/api/routes/__pycache__/models.cpython-310.pyc differ
|
|
|
api/routes/__pycache__/sync.cpython-310.pyc
CHANGED
|
Binary files a/api/routes/__pycache__/sync.cpython-310.pyc and b/api/routes/__pycache__/sync.cpython-310.pyc differ
|
|
|
api/routes/datasets.py
CHANGED
|
@@ -1,36 +1,17 @@
|
|
| 1 |
"""
|
| 2 |
-
api/routes/datasets.py β Dataset
|
| 3 |
-
|
| 4 |
-
Routes:
|
| 5 |
-
GET /datasets β list/search datasets
|
| 6 |
-
GET /datasets/{id} β dataset detail
|
| 7 |
-
POST /datasets/search/roboflow β search Roboflow Universe (real-time)
|
| 8 |
-
POST /datasets/sync/roboflow β sync workspace datasets to local DB
|
| 9 |
-
POST /datasets/{id}/import β initiate dataset import job
|
| 10 |
-
GET /datasets/{id}/images β paginated viewer (images + annotations)
|
| 11 |
-
GET /datasets/{id}/image/{img} β serve raw image bytes
|
| 12 |
-
GET /datasets/jobs β list import jobs
|
| 13 |
-
GET /datasets/jobs/{job_id} β single job status
|
| 14 |
-
POST /datasets/{id}/star β toggle starred
|
| 15 |
-
DELETE /datasets/{id} β delete dataset record (+ local files)
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
-
from pathlib import Path
|
| 20 |
from typing import Optional
|
| 21 |
from datetime import datetime
|
| 22 |
|
| 23 |
-
from fastapi import APIRouter,
|
| 24 |
-
from fastapi.responses import FileResponse, Response
|
| 25 |
|
| 26 |
from adapters.roboflow_adapter import RoboflowAdapter
|
| 27 |
from datasets import registry as ds_reg
|
| 28 |
-
from datasets.import_service import start_import
|
| 29 |
-
from datasets.viewer_service import get_universal_viewer_page, get_viewer_page, resolve_image_path
|
| 30 |
from models.dataset import (
|
| 31 |
-
Dataset,
|
| 32 |
-
DatasetFormat, DatasetStatus, ImportRequest, ImportResponse,
|
| 33 |
-
RoboflowSearchRequest, ViewerPage, UniversalViewerPage, row_to_dataset,
|
| 34 |
)
|
| 35 |
from models.analytics import DatasetAnalytics, SplitAnalytics, QualityIssues, ClassDistributionItem
|
| 36 |
from observability.logger import audit, get_logger
|
|
@@ -46,18 +27,13 @@ router = APIRouter(prefix="/datasets", tags=["datasets"])
|
|
| 46 |
async def get_dataset_analytics(dataset_id: str):
|
| 47 |
"""
|
| 48 |
Fetch comprehensive analytics for a dataset.
|
| 49 |
-
Exposes health scores, quality issues, and distributions.
|
| 50 |
"""
|
| 51 |
ds = await ds_reg.get_dataset(dataset_id)
|
| 52 |
if not ds:
|
| 53 |
raise HTTPException(404, f"Dataset {dataset_id!r} not found")
|
| 54 |
|
| 55 |
-
# In a real scenario, this would be computed or fetched from a dedicated analytics table.
|
| 56 |
-
# Here we derive it from the dataset's stats and mock the detailed distributions.
|
| 57 |
stats = ds.stats
|
| 58 |
|
| 59 |
-
# Mock some distributions if they aren't in the stats yet
|
| 60 |
-
# to match the frontend expectations in DatasetAnalytics.tsx
|
| 61 |
analytics = DatasetAnalytics(
|
| 62 |
dataset_id=dataset_id,
|
| 63 |
healthScore=stats.health_score,
|
|
@@ -106,49 +82,12 @@ async def list_datasets(
|
|
| 106 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 107 |
|
| 108 |
|
| 109 |
-
@router.get("/jobs", response_model=list[DatasetJob])
|
| 110 |
-
async def list_jobs(limit: int = Query(50, ge=1, le=500)):
|
| 111 |
-
return await ds_reg.get_all_jobs(limit=limit)
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
@router.get("/jobs/{job_id}", response_model=DatasetJob)
|
| 115 |
-
async def get_job(job_id: str):
|
| 116 |
-
job = await ds_reg.get_job(job_id)
|
| 117 |
-
if not job:
|
| 118 |
-
raise HTTPException(404, f"Job {job_id!r} not found")
|
| 119 |
-
return job
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
@router.post("/jobs/{job_id}/stop")
|
| 123 |
-
async def stop_job(job_id: str):
|
| 124 |
-
"""Cancel a running import job."""
|
| 125 |
-
# Logic to cancel the asyncio task would go here
|
| 126 |
-
# For now, we update the status in the DB
|
| 127 |
-
await ds_reg.update_job(job_id, status="failed", error="Cancelled by user", ended_at=datetime.utcnow().isoformat())
|
| 128 |
-
return {"status": "success", "message": "Job stop requested"}
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
@router.post("/jobs/{job_id}/pause")
|
| 132 |
-
async def pause_job(job_id: str):
|
| 133 |
-
"""Pause a running import job."""
|
| 134 |
-
await ds_reg.update_job(job_id, status="paused")
|
| 135 |
-
return {"status": "success", "message": "Job pause requested"}
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
@router.post("/jobs/{job_id}/resume")
|
| 139 |
-
async def resume_job(job_id: str):
|
| 140 |
-
"""Resume a paused import job."""
|
| 141 |
-
await ds_reg.update_job(job_id, status="running")
|
| 142 |
-
return {"status": "success", "message": "Job resume requested"}
|
| 143 |
-
|
| 144 |
-
|
| 145 |
# ββ Roboflow Search & Sync ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 146 |
|
| 147 |
@router.post("/search/roboflow", response_model=list[DatasetSummary])
|
| 148 |
async def search_roboflow(req: RoboflowSearchRequest):
|
| 149 |
"""
|
| 150 |
Live search Roboflow Universe. Results are cached for 1 hour.
|
| 151 |
-
Also upserts results into local DB so they appear in /datasets.
|
| 152 |
"""
|
| 153 |
try:
|
| 154 |
datasets = await RoboflowAdapter.search_datasets(
|
|
@@ -162,7 +101,7 @@ async def search_roboflow(req: RoboflowSearchRequest):
|
|
| 162 |
log.error("roboflow_search_error", error=str(exc))
|
| 163 |
raise HTTPException(502, f"Roboflow API error: {exc}")
|
| 164 |
|
| 165 |
-
# Upsert to
|
| 166 |
await ds_reg.bulk_upsert_datasets(datasets)
|
| 167 |
await audit("roboflow_search", {"query": req.query, "count": len(datasets)})
|
| 168 |
return [_to_summary(d) for d in datasets]
|
|
@@ -173,7 +112,7 @@ async def sync_roboflow_workspace(
|
|
| 173 |
api_key: str = Query(..., description="Roboflow API key"),
|
| 174 |
workspace: str = Query(..., description="Workspace slug"),
|
| 175 |
):
|
| 176 |
-
"""Sync all datasets from a Roboflow workspace into
|
| 177 |
try:
|
| 178 |
datasets = await RoboflowAdapter.list_workspace_datasets(api_key, workspace)
|
| 179 |
except Exception as exc:
|
|
@@ -192,193 +131,7 @@ async def get_dataset(dataset_id: str):
|
|
| 192 |
return ds
|
| 193 |
|
| 194 |
|
| 195 |
-
# ββ
|
| 196 |
-
|
| 197 |
-
@router.post("/{dataset_id}/import", response_model=ImportResponse)
|
| 198 |
-
async def import_dataset(dataset_id: str, req: ImportRequest):
|
| 199 |
-
"""
|
| 200 |
-
Initiate a background import job for a dataset.
|
| 201 |
-
Supports sources: roboflow | roboflow_curl | huggingface | local
|
| 202 |
-
"""
|
| 203 |
-
req.dataset_id = dataset_id # enforce consistency
|
| 204 |
-
|
| 205 |
-
# Sources that are discovered outside the registry must be auto-registered.
|
| 206 |
-
auto_register_sources = {DatasetSource.huggingface, DatasetSource.roboflow_curl, DatasetSource.local}
|
| 207 |
-
if req.source in auto_register_sources:
|
| 208 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 209 |
-
if not ds:
|
| 210 |
-
# Determine human-readable name
|
| 211 |
-
if req.source == DatasetSource.huggingface and req.hf_dataset_id:
|
| 212 |
-
name = req.hf_dataset_id
|
| 213 |
-
roboflow_ref = req.hf_dataset_id
|
| 214 |
-
fmt = DatasetFormat.json
|
| 215 |
-
src = DatasetSource.huggingface
|
| 216 |
-
|
| 217 |
-
elif req.source == DatasetSource.local:
|
| 218 |
-
# local: use provided name or folder name from path
|
| 219 |
-
# Try req.local_path first, then req.name, then fallback to dataset_id
|
| 220 |
-
path_to_use = req.local_path or req.name or ""
|
| 221 |
-
name = req.name or (Path(path_to_use).name if path_to_use else dataset_id)
|
| 222 |
-
roboflow_ref = None
|
| 223 |
-
fmt = DatasetFormat.custom
|
| 224 |
-
src = DatasetSource.local
|
| 225 |
-
else:
|
| 226 |
-
# roboflow_curl: use provided dataset_name or fall back to dataset_id
|
| 227 |
-
name = req.dataset_name or dataset_id
|
| 228 |
-
roboflow_ref = None
|
| 229 |
-
fmt = _curl_format_to_enum(req.curl_format)
|
| 230 |
-
src = DatasetSource.roboflow_curl
|
| 231 |
-
|
| 232 |
-
stub = Dataset(
|
| 233 |
-
id=dataset_id,
|
| 234 |
-
name=name,
|
| 235 |
-
task=DatasetTask.detection,
|
| 236 |
-
format=fmt,
|
| 237 |
-
source=src,
|
| 238 |
-
status=DatasetStatus.available,
|
| 239 |
-
roboflow_id=roboflow_ref,
|
| 240 |
-
created_at=datetime.utcnow().isoformat(),
|
| 241 |
-
)
|
| 242 |
-
await ds_reg.upsert_dataset(stub)
|
| 243 |
-
log.info("dataset_auto_registered", dataset_id=dataset_id, source=str(req.source))
|
| 244 |
-
else:
|
| 245 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 246 |
-
if not ds:
|
| 247 |
-
raise HTTPException(404, f"Dataset {dataset_id!r} not found in registry. "
|
| 248 |
-
"Run /datasets/sync/roboflow first.")
|
| 249 |
-
|
| 250 |
-
try:
|
| 251 |
-
job_id = await start_import(req)
|
| 252 |
-
except ValueError as exc:
|
| 253 |
-
raise HTTPException(400, str(exc))
|
| 254 |
-
|
| 255 |
-
await audit("dataset_import_requested", {"dataset_id": dataset_id, "source": str(req.source)})
|
| 256 |
-
return ImportResponse(
|
| 257 |
-
job_id = job_id,
|
| 258 |
-
dataset_id = dataset_id,
|
| 259 |
-
status = "queued",
|
| 260 |
-
message = "Import job created successfully",
|
| 261 |
-
)
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
def _curl_format_to_enum(curl_format: str | None) -> DatasetFormat:
|
| 265 |
-
"""Map Roboflow export format string from cURL to DatasetFormat enum."""
|
| 266 |
-
if not curl_format:
|
| 267 |
-
return DatasetFormat.yolo
|
| 268 |
-
fmt = curl_format.lower()
|
| 269 |
-
if "yolo" in fmt:
|
| 270 |
-
return DatasetFormat.yolo
|
| 271 |
-
if "coco" in fmt:
|
| 272 |
-
return DatasetFormat.coco
|
| 273 |
-
if "voc" in fmt or "pascal" in fmt:
|
| 274 |
-
return DatasetFormat.voc
|
| 275 |
-
if "tfrecord" in fmt:
|
| 276 |
-
return DatasetFormat.tfrecord
|
| 277 |
-
if "csv" in fmt:
|
| 278 |
-
return DatasetFormat.csv
|
| 279 |
-
if "json" in fmt or "createml" in fmt:
|
| 280 |
-
return DatasetFormat.json
|
| 281 |
-
return DatasetFormat.yolo
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
# ββ Viewer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 285 |
-
|
| 286 |
-
@router.get("/{dataset_id}/universal", response_model=UniversalViewerPage)
|
| 287 |
-
async def get_universal_items(
|
| 288 |
-
dataset_id: str,
|
| 289 |
-
page: int = Query(0, ge=0),
|
| 290 |
-
page_size: int = Query(20, ge=1, le=100),
|
| 291 |
-
split: Optional[str] = Query(None, regex="^(train|val|test)$"),
|
| 292 |
-
class_label: Optional[str] = Query(None),
|
| 293 |
-
):
|
| 294 |
-
"""
|
| 295 |
-
Polymorphic dataset item viewer (UDV).
|
| 296 |
-
Supports Vision, NLP, and Tabular data via the Universal Dataset Item schema.
|
| 297 |
-
"""
|
| 298 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 299 |
-
if not ds:
|
| 300 |
-
raise HTTPException(404, f"Dataset {dataset_id!r} not found")
|
| 301 |
-
|
| 302 |
-
# Allow viewing even if not fully imported for NLP/Tabular if files exist,
|
| 303 |
-
# but for Vision we usually need the index.
|
| 304 |
-
return await get_universal_viewer_page(dataset_id, page, page_size, split, class_label)
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
@router.get("/{dataset_id}/images", response_model=ViewerPage)
|
| 308 |
-
async def get_images(
|
| 309 |
-
dataset_id: str,
|
| 310 |
-
page: int = Query(0, ge=0),
|
| 311 |
-
page_size: int = Query(20, ge=1, le=100),
|
| 312 |
-
split: Optional[str] = Query(None, regex="^(train|val|test)$"),
|
| 313 |
-
class_label: Optional[str] = Query(None),
|
| 314 |
-
):
|
| 315 |
-
"""
|
| 316 |
-
Paginated image + annotation data for the viewer.
|
| 317 |
-
Annotations are returned in normalised [0β1] coordinates.
|
| 318 |
-
Supports filtering by split and class label.
|
| 319 |
-
"""
|
| 320 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 321 |
-
if not ds:
|
| 322 |
-
raise HTTPException(404, f"Dataset {dataset_id!r} not found")
|
| 323 |
-
if ds.status != "imported":
|
| 324 |
-
raise HTTPException(409, f"Dataset is not imported yet (status: {ds.status})")
|
| 325 |
-
|
| 326 |
-
return await get_viewer_page(dataset_id, page, page_size, split, class_label)
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
@router.get("/{dataset_id}/stats", response_model=dict)
|
| 330 |
-
async def get_dataset_stats(dataset_id: str):
|
| 331 |
-
"""Return pre-computed class distributions and split statistics."""
|
| 332 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 333 |
-
if not ds:
|
| 334 |
-
raise HTTPException(404, f"Dataset {dataset_id!r} not found")
|
| 335 |
-
|
| 336 |
-
return await ds_reg.get_dataset_stats(dataset_id)
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
@router.get("/{dataset_id}/image/{image_id}")
|
| 340 |
-
async def serve_image(dataset_id: str, image_id: str):
|
| 341 |
-
"""Serve raw image bytes for the viewer (cached by browser)."""
|
| 342 |
-
path = await resolve_image_path(dataset_id, image_id)
|
| 343 |
-
if path is None:
|
| 344 |
-
raise HTTPException(404, "Image not found or dataset not imported")
|
| 345 |
-
|
| 346 |
-
suffix = path.suffix.lower()
|
| 347 |
-
media_types = {
|
| 348 |
-
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
| 349 |
-
".png": "image/png", ".bmp": "image/bmp",
|
| 350 |
-
".webp": "image/webp",
|
| 351 |
-
}
|
| 352 |
-
media_type = media_types.get(suffix, "application/octet-stream")
|
| 353 |
-
return FileResponse(
|
| 354 |
-
path = str(path),
|
| 355 |
-
media_type = media_type,
|
| 356 |
-
headers = {"Cache-Control": "public, max-age=86400"},
|
| 357 |
-
)
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
@router.get("/{dataset_id}/annotations", response_model=dict)
|
| 361 |
-
async def get_annotations_summary(dataset_id: str):
|
| 362 |
-
"""Return class distribution summary from the annotations index."""
|
| 363 |
-
from database.connection import get_db
|
| 364 |
-
db = await get_db()
|
| 365 |
-
async with db.execute(
|
| 366 |
-
"""SELECT label, COUNT(*) as count
|
| 367 |
-
FROM dataset_annotations
|
| 368 |
-
WHERE dataset_id=?
|
| 369 |
-
GROUP BY label
|
| 370 |
-
ORDER BY count DESC""",
|
| 371 |
-
(dataset_id,),
|
| 372 |
-
) as cur:
|
| 373 |
-
rows = await cur.fetchall()
|
| 374 |
-
return {
|
| 375 |
-
"dataset_id": dataset_id,
|
| 376 |
-
"class_distribution": [{"label": r["label"], "count": r["count"]} for r in rows],
|
| 377 |
-
"total_annotations": sum(r["count"] for r in rows),
|
| 378 |
-
}
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
# ββ Star / Delete βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 382 |
|
| 383 |
@router.post("/{dataset_id}/star", response_model=dict)
|
| 384 |
async def toggle_star(dataset_id: str):
|
|
@@ -386,31 +139,9 @@ async def toggle_star(dataset_id: str):
|
|
| 386 |
return {"dataset_id": dataset_id, "starred": new_val}
|
| 387 |
|
| 388 |
|
| 389 |
-
@router.delete("/{dataset_id}", response_model=dict)
|
| 390 |
-
async def delete_dataset(
|
| 391 |
-
dataset_id: str,
|
| 392 |
-
delete_files: bool = Query(False, description="Also remove local files from disk"),
|
| 393 |
-
):
|
| 394 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 395 |
-
if not ds:
|
| 396 |
-
raise HTTPException(404, f"Dataset {dataset_id!r} not found")
|
| 397 |
-
|
| 398 |
-
if delete_files and ds.local_path:
|
| 399 |
-
import shutil
|
| 400 |
-
local = Path(ds.local_path)
|
| 401 |
-
if local.exists() and local.is_dir():
|
| 402 |
-
shutil.rmtree(str(local), ignore_errors=True)
|
| 403 |
-
log.info("dataset_files_deleted", path=str(local))
|
| 404 |
-
|
| 405 |
-
deleted = await ds_reg.delete_dataset(dataset_id)
|
| 406 |
-
await audit("dataset_deleted", {"dataset_id": dataset_id, "files_deleted": delete_files})
|
| 407 |
-
return {"deleted": deleted, "dataset_id": dataset_id}
|
| 408 |
-
|
| 409 |
-
|
| 410 |
# ββ Helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
|
| 412 |
def _to_summary(d: Dataset) -> DatasetSummary:
|
| 413 |
-
# Use 0.0 as default health_score if stats is missing or health_score is not present
|
| 414 |
health_score = 0.0
|
| 415 |
try:
|
| 416 |
if hasattr(d, 'stats') and d.stats:
|
|
|
|
| 1 |
"""
|
| 2 |
+
api/routes/datasets.py β Dataset Discovery REST API (Cloud Registry Version).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 6 |
from typing import Optional
|
| 7 |
from datetime import datetime
|
| 8 |
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Query
|
|
|
|
| 10 |
|
| 11 |
from adapters.roboflow_adapter import RoboflowAdapter
|
| 12 |
from datasets import registry as ds_reg
|
|
|
|
|
|
|
| 13 |
from models.dataset import (
|
| 14 |
+
Dataset, DatasetSummary, RoboflowSearchRequest,
|
|
|
|
|
|
|
| 15 |
)
|
| 16 |
from models.analytics import DatasetAnalytics, SplitAnalytics, QualityIssues, ClassDistributionItem
|
| 17 |
from observability.logger import audit, get_logger
|
|
|
|
| 27 |
async def get_dataset_analytics(dataset_id: str):
|
| 28 |
"""
|
| 29 |
Fetch comprehensive analytics for a dataset.
|
|
|
|
| 30 |
"""
|
| 31 |
ds = await ds_reg.get_dataset(dataset_id)
|
| 32 |
if not ds:
|
| 33 |
raise HTTPException(404, f"Dataset {dataset_id!r} not found")
|
| 34 |
|
|
|
|
|
|
|
| 35 |
stats = ds.stats
|
| 36 |
|
|
|
|
|
|
|
| 37 |
analytics = DatasetAnalytics(
|
| 38 |
dataset_id=dataset_id,
|
| 39 |
healthScore=stats.health_score,
|
|
|
|
| 82 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# ββ Roboflow Search & Sync ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
|
| 87 |
@router.post("/search/roboflow", response_model=list[DatasetSummary])
|
| 88 |
async def search_roboflow(req: RoboflowSearchRequest):
|
| 89 |
"""
|
| 90 |
Live search Roboflow Universe. Results are cached for 1 hour.
|
|
|
|
| 91 |
"""
|
| 92 |
try:
|
| 93 |
datasets = await RoboflowAdapter.search_datasets(
|
|
|
|
| 101 |
log.error("roboflow_search_error", error=str(exc))
|
| 102 |
raise HTTPException(502, f"Roboflow API error: {exc}")
|
| 103 |
|
| 104 |
+
# Upsert to registry DB
|
| 105 |
await ds_reg.bulk_upsert_datasets(datasets)
|
| 106 |
await audit("roboflow_search", {"query": req.query, "count": len(datasets)})
|
| 107 |
return [_to_summary(d) for d in datasets]
|
|
|
|
| 112 |
api_key: str = Query(..., description="Roboflow API key"),
|
| 113 |
workspace: str = Query(..., description="Workspace slug"),
|
| 114 |
):
|
| 115 |
+
"""Sync all datasets from a Roboflow workspace into the global registry."""
|
| 116 |
try:
|
| 117 |
datasets = await RoboflowAdapter.list_workspace_datasets(api_key, workspace)
|
| 118 |
except Exception as exc:
|
|
|
|
| 131 |
return ds
|
| 132 |
|
| 133 |
|
| 134 |
+
# ββ Star βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
@router.post("/{dataset_id}/star", response_model=dict)
|
| 137 |
async def toggle_star(dataset_id: str):
|
|
|
|
| 139 |
return {"dataset_id": dataset_id, "starred": new_val}
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
# ββ Helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
|
| 144 |
def _to_summary(d: Dataset) -> DatasetSummary:
|
|
|
|
| 145 |
health_score = 0.0
|
| 146 |
try:
|
| 147 |
if hasattr(d, 'stats') and d.stats:
|
api/routes/models.py
CHANGED
|
@@ -1,19 +1,15 @@
|
|
| 1 |
"""
|
| 2 |
-
api/routes/models.py β /models REST endpoints.
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
from typing import Annotated
|
| 7 |
|
| 8 |
-
from fastapi import APIRouter, HTTPException, Query
|
| 9 |
from models.model import Model
|
| 10 |
from observability.logger import audit, get_logger
|
| 11 |
-
from registry.registry import
|
| 12 |
-
from projects.service import get_active_project_id, import_local_model
|
| 13 |
-
from projects.registry import get_project
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
import os
|
| 16 |
-
import tempfile
|
| 17 |
|
| 18 |
log = get_logger("api.models")
|
| 19 |
router = APIRouter(prefix="/models", tags=["models"])
|
|
@@ -26,98 +22,30 @@ async def index(
|
|
| 26 |
framework: Annotated[list[str] | None, Query()] = None,
|
| 27 |
hardware: Annotated[list[str] | None, Query()] = None,
|
| 28 |
source: Annotated[list[str] | None, Query()] = None,
|
| 29 |
-
downloaded: Annotated[bool | None, Query()] = None,
|
| 30 |
sort_by: Annotated[str, Query()] = "downloads",
|
| 31 |
sort_dir: Annotated[str, Query()] = "desc",
|
| 32 |
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
| 33 |
offset: Annotated[int, Query(ge=0)] = 0,
|
| 34 |
-
project_id: Annotated[str | None, Query()] = None,
|
| 35 |
) -> list[Model]:
|
| 36 |
"""
|
| 37 |
-
List and search models.
|
| 38 |
-
Supports FTS search + server-side filtering.
|
| 39 |
-
Target: < 100ms for up to 5 000 models.
|
| 40 |
"""
|
| 41 |
-
effective_project_id = project_id or await get_active_project_id()
|
| 42 |
-
|
| 43 |
models = await list_models(
|
| 44 |
search=search,
|
| 45 |
tasks=task,
|
| 46 |
frameworks=framework,
|
| 47 |
hardware=hardware,
|
| 48 |
sources=source,
|
| 49 |
-
downloaded=downloaded,
|
| 50 |
sort_by=sort_by,
|
| 51 |
sort_dir=sort_dir,
|
| 52 |
limit=limit,
|
| 53 |
offset=offset,
|
| 54 |
-
project_id=effective_project_id,
|
| 55 |
)
|
| 56 |
|
| 57 |
-
# If we have an active project, derive cache state from its workspace.
|
| 58 |
-
# This makes "downloaded" and "local_path" reflect the *current project*.
|
| 59 |
-
if effective_project_id:
|
| 60 |
-
proj = await get_project(effective_project_id)
|
| 61 |
-
if proj:
|
| 62 |
-
project_models_dir = Path(proj.path) / "models"
|
| 63 |
-
|
| 64 |
-
updated: list[Model] = []
|
| 65 |
-
for m in models:
|
| 66 |
-
model_dir = project_models_dir / m.id
|
| 67 |
-
if model_dir.exists() and model_dir.is_dir():
|
| 68 |
-
# Pick the first file in the model directory (best-effort).
|
| 69 |
-
found_file: str | None = None
|
| 70 |
-
try:
|
| 71 |
-
for p in model_dir.rglob("*"):
|
| 72 |
-
if p.is_file():
|
| 73 |
-
found_file = str(p)
|
| 74 |
-
break
|
| 75 |
-
except Exception:
|
| 76 |
-
found_file = None
|
| 77 |
-
|
| 78 |
-
if found_file:
|
| 79 |
-
updated.append(m.model_copy(update={"downloaded": True, "local_path": found_file}))
|
| 80 |
-
continue
|
| 81 |
-
|
| 82 |
-
# Not present in this project β treat as not cached for this project.
|
| 83 |
-
updated.append(m.model_copy(update={"downloaded": False, "local_path": None}))
|
| 84 |
-
|
| 85 |
-
models = updated
|
| 86 |
-
|
| 87 |
await audit("api_list_models", payload={"count": len(models), "search": search})
|
| 88 |
return models
|
| 89 |
|
| 90 |
|
| 91 |
-
@router.post("/import", response_model=Model)
|
| 92 |
-
async def import_model(
|
| 93 |
-
name: Annotated[str, Form()],
|
| 94 |
-
task: Annotated[str, Form()],
|
| 95 |
-
framework: Annotated[str, Form()],
|
| 96 |
-
file: UploadFile = File(...),
|
| 97 |
-
) -> Model:
|
| 98 |
-
"""Import a local model file into the active project."""
|
| 99 |
-
# Save uploaded file to a temporary location
|
| 100 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename or "")[1]) as tmp:
|
| 101 |
-
content = await file.read()
|
| 102 |
-
tmp.write(content)
|
| 103 |
-
tmp_path = tmp.name
|
| 104 |
-
|
| 105 |
-
try:
|
| 106 |
-
model = await import_local_model(
|
| 107 |
-
name=name,
|
| 108 |
-
task=task,
|
| 109 |
-
framework=framework,
|
| 110 |
-
source_file_path=tmp_path
|
| 111 |
-
)
|
| 112 |
-
return model
|
| 113 |
-
except Exception as e:
|
| 114 |
-
log.error("model_import_failed", error=str(e))
|
| 115 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 116 |
-
finally:
|
| 117 |
-
if os.path.exists(tmp_path):
|
| 118 |
-
os.remove(tmp_path)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
@router.get("/{model_id}", response_model=Model)
|
| 122 |
async def detail(model_id: str) -> Model:
|
| 123 |
model = await get_model(model_id)
|
|
|
|
| 1 |
"""
|
| 2 |
+
api/routes/models.py β /models REST endpoints (Cloud Registry Version).
|
| 3 |
+
Provides global model discovery and metadata.
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
from typing import Annotated
|
| 8 |
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 10 |
from models.model import Model
|
| 11 |
from observability.logger import audit, get_logger
|
| 12 |
+
from registry.registry import get_model, list_models
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
log = get_logger("api.models")
|
| 15 |
router = APIRouter(prefix="/models", tags=["models"])
|
|
|
|
| 22 |
framework: Annotated[list[str] | None, Query()] = None,
|
| 23 |
hardware: Annotated[list[str] | None, Query()] = None,
|
| 24 |
source: Annotated[list[str] | None, Query()] = None,
|
|
|
|
| 25 |
sort_by: Annotated[str, Query()] = "downloads",
|
| 26 |
sort_dir: Annotated[str, Query()] = "desc",
|
| 27 |
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
| 28 |
offset: Annotated[int, Query(ge=0)] = 0,
|
|
|
|
| 29 |
) -> list[Model]:
|
| 30 |
"""
|
| 31 |
+
List and search models in the global registry.
|
|
|
|
|
|
|
| 32 |
"""
|
|
|
|
|
|
|
| 33 |
models = await list_models(
|
| 34 |
search=search,
|
| 35 |
tasks=task,
|
| 36 |
frameworks=framework,
|
| 37 |
hardware=hardware,
|
| 38 |
sources=source,
|
|
|
|
| 39 |
sort_by=sort_by,
|
| 40 |
sort_dir=sort_dir,
|
| 41 |
limit=limit,
|
| 42 |
offset=offset,
|
|
|
|
| 43 |
)
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
await audit("api_list_models", payload={"count": len(models), "search": search})
|
| 46 |
return models
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
@router.get("/{model_id}", response_model=Model)
|
| 50 |
async def detail(model_id: str) -> Model:
|
| 51 |
model = await get_model(model_id)
|
api/routes/projects.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
"""api/routes/projects.py β /projects REST endpoints."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
from fastapi import APIRouter, HTTPException, Query
|
| 6 |
-
|
| 7 |
-
from models.project import Project
|
| 8 |
-
from observability.logger import audit
|
| 9 |
-
from projects.registry import delete_project, get_project, list_projects, touch_last_opened, upsert_project
|
| 10 |
-
from projects.service import set_active_project
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
router = APIRouter(prefix="/projects", tags=["projects"])
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
@router.get("", response_model=list[Project])
|
| 17 |
-
async def projects_list(
|
| 18 |
-
limit: int = Query(200, ge=1, le=1000),
|
| 19 |
-
offset: int = Query(0, ge=0),
|
| 20 |
-
) -> list[Project]:
|
| 21 |
-
projects = await list_projects(limit=limit, offset=offset)
|
| 22 |
-
await audit("api_list_projects", payload={"count": len(projects)})
|
| 23 |
-
return projects
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
@router.post("", response_model=Project)
|
| 27 |
-
async def projects_upsert(project: Project) -> Project:
|
| 28 |
-
# Ensure project.created_at and last_opened are set if missing
|
| 29 |
-
if not project.created_at:
|
| 30 |
-
project.created_at = datetime.now(timezone.utc).isoformat()
|
| 31 |
-
if not project.last_opened:
|
| 32 |
-
project.last_opened = datetime.now(timezone.utc).isoformat()
|
| 33 |
-
|
| 34 |
-
await upsert_project(project)
|
| 35 |
-
await audit("api_upsert_project", payload={"project_id": project.id})
|
| 36 |
-
return project
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
@router.post("/{project_id}/open", status_code=204, response_model=None)
|
| 40 |
-
async def projects_open(project_id: str) -> None:
|
| 41 |
-
await touch_last_opened(project_id)
|
| 42 |
-
project = await get_project(project_id)
|
| 43 |
-
if project:
|
| 44 |
-
await set_active_project(project.id, project.path)
|
| 45 |
-
await audit("api_open_project", payload={"project_id": project_id})
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@router.delete("/{project_id}", status_code=204, response_model=None)
|
| 49 |
-
async def projects_delete(project_id: str) -> None:
|
| 50 |
-
ok = await delete_project(project_id)
|
| 51 |
-
if not ok:
|
| 52 |
-
raise HTTPException(status_code=404, detail=f"Project '{project_id}' not found")
|
| 53 |
-
await audit("api_delete_project", payload={"project_id": project_id})
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
config.py
CHANGED
|
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
|
| 26 |
# ββ Storage βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
base_dir: Path = Path(__file__).resolve().parents[1]
|
| 28 |
data_dir: Path = base_dir / "data"
|
|
|
|
| 29 |
db_path: Path = data_dir / "modelzoo.db"
|
| 30 |
|
| 31 |
# ββ Search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -42,6 +43,7 @@ class Settings(BaseSettings):
|
|
| 42 |
|
| 43 |
def ensure_dirs(self) -> None:
|
| 44 |
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
settings = Settings()
|
|
|
|
| 26 |
# ββ Storage βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
base_dir: Path = Path(__file__).resolve().parents[1]
|
| 28 |
data_dir: Path = base_dir / "data"
|
| 29 |
+
logs_dir: Path = data_dir / "logs"
|
| 30 |
db_path: Path = data_dir / "modelzoo.db"
|
| 31 |
|
| 32 |
# ββ Search ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 43 |
|
| 44 |
def ensure_dirs(self) -> None:
|
| 45 |
self.data_dir.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
| 47 |
|
| 48 |
|
| 49 |
settings = Settings()
|
datasets/import_service.py
DELETED
|
@@ -1,589 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
datasets/import_service.py β Dataset Import Pipeline.
|
| 3 |
-
|
| 4 |
-
Pipeline stages:
|
| 5 |
-
1. Create job record
|
| 6 |
-
2. Download dataset zip (chunked, progress-tracked)
|
| 7 |
-
3. Extract zip safely (path-traversal protected)
|
| 8 |
-
4. Detect annotation format & task type
|
| 9 |
-
5. Index images into dataset_images table
|
| 10 |
-
6. Parse & store metadata (Stats only, annotations are read-on-demand)
|
| 11 |
-
7. Update dataset stats (images, classes, size)
|
| 12 |
-
8. Mark job completed / failed
|
| 13 |
-
|
| 14 |
-
All stages run as background tasks.
|
| 15 |
-
Supports Roboflow, HuggingFace, and local file/folder imports.
|
| 16 |
-
"""
|
| 17 |
-
from __future__ import annotations
|
| 18 |
-
|
| 19 |
-
import asyncio
|
| 20 |
-
import hashlib
|
| 21 |
-
import os
|
| 22 |
-
import shutil
|
| 23 |
-
import uuid
|
| 24 |
-
import zipfile
|
| 25 |
-
from datetime import datetime
|
| 26 |
-
from pathlib import Path
|
| 27 |
-
from typing import Optional, List, Dict, Any, Tuple
|
| 28 |
-
|
| 29 |
-
import aiofiles
|
| 30 |
-
import httpx
|
| 31 |
-
from huggingface_hub import snapshot_download
|
| 32 |
-
|
| 33 |
-
from config import settings
|
| 34 |
-
from . import registry as ds_reg
|
| 35 |
-
from .format_adapters import (
|
| 36 |
-
YOLOAdapter, COCOAdapter, VOCAdapter, CreateMLAdapter,
|
| 37 |
-
RoboflowClassificationAdapter, NLPAdapter, TabularAdapter
|
| 38 |
-
)
|
| 39 |
-
from .base_adapter import DatasetAdapter
|
| 40 |
-
from .annotation_parser import _img_dimensions
|
| 41 |
-
from observability.logger import audit, get_logger
|
| 42 |
-
from models.dataset import DatasetStatus, DatasetTask, ImportRequest, Dataset
|
| 43 |
-
|
| 44 |
-
log = get_logger("import_service")
|
| 45 |
-
|
| 46 |
-
ADAPTERS: List[DatasetAdapter] = [
|
| 47 |
-
YOLOAdapter(),
|
| 48 |
-
COCOAdapter(),
|
| 49 |
-
VOCAdapter(),
|
| 50 |
-
CreateMLAdapter(),
|
| 51 |
-
RoboflowClassificationAdapter(),
|
| 52 |
-
NLPAdapter(),
|
| 53 |
-
TabularAdapter(),
|
| 54 |
-
]
|
| 55 |
-
|
| 56 |
-
def get_adapter_for_path(path: Path) -> DatasetAdapter | None:
|
| 57 |
-
for adapter in ADAPTERS:
|
| 58 |
-
if adapter.detect(path):
|
| 59 |
-
return adapter
|
| 60 |
-
return None
|
| 61 |
-
|
| 62 |
-
async def recover_stale_jobs() -> None:
|
| 63 |
-
"""Cleanup dataset import jobs that were left in 'running' or 'queued' state."""
|
| 64 |
-
await ds_reg.cleanup_stale_jobs()
|
| 65 |
-
|
| 66 |
-
def _dataset_path(dataset_id: str) -> Path:
|
| 67 |
-
return settings.datasets_dir / dataset_id
|
| 68 |
-
|
| 69 |
-
# ββ Entry Point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
-
|
| 71 |
-
async def start_import(req: ImportRequest) -> str:
|
| 72 |
-
"""Entry point to initiate a background import job."""
|
| 73 |
-
job_id = f"job-{uuid.uuid4().hex[:8]}"
|
| 74 |
-
|
| 75 |
-
# Create initial job record
|
| 76 |
-
await ds_reg.update_job(
|
| 77 |
-
job_id,
|
| 78 |
-
dataset_id=req.dataset_id,
|
| 79 |
-
status="queued",
|
| 80 |
-
progress=0,
|
| 81 |
-
message="Import queued",
|
| 82 |
-
type=str(req.source)
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
# Launch background task
|
| 86 |
-
asyncio.create_task(_run_pipeline(job_id, req, req.dataset_name or req.dataset_id))
|
| 87 |
-
|
| 88 |
-
return job_id
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
# ββ Pipeline orchestrator ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
-
|
| 93 |
-
async def _run_pipeline(job_id: str, req: ImportRequest, dataset_name: str) -> None:
|
| 94 |
-
started = datetime.utcnow().isoformat()
|
| 95 |
-
await ds_reg.update_job(job_id, status="running", started_at=started, message="Starting import")
|
| 96 |
-
await ds_reg.update_dataset_status(req.dataset_id, DatasetStatus.importing, progress=0.01)
|
| 97 |
-
|
| 98 |
-
try:
|
| 99 |
-
# Stage 1 β Resolve download URL or local path
|
| 100 |
-
source_path = await _stage_acquire(job_id, req)
|
| 101 |
-
|
| 102 |
-
# Stage 2 β Extract / Prepare Directory
|
| 103 |
-
extract_dir = await _stage_extract(job_id, req.dataset_id, source_path)
|
| 104 |
-
|
| 105 |
-
# Stage 3 β Detect adapter and Task
|
| 106 |
-
await ds_reg.update_job(job_id, progress=0.55, message="Detecting dataset format...")
|
| 107 |
-
adapter = await asyncio.to_thread(get_adapter_for_path, extract_dir)
|
| 108 |
-
|
| 109 |
-
if not adapter:
|
| 110 |
-
log.warning("no_adapter_found_generic_fallback", dataset_id=req.dataset_id)
|
| 111 |
-
image_records = await asyncio.to_thread(_scan_images_generic, req.dataset_id, extract_dir)
|
| 112 |
-
class_names = []
|
| 113 |
-
task = DatasetTask.classification
|
| 114 |
-
fmt_name = "custom"
|
| 115 |
-
else:
|
| 116 |
-
task = adapter.get_task(extract_dir)
|
| 117 |
-
fmt_name = adapter.__class__.__name__.replace("Adapter", "").lower()
|
| 118 |
-
|
| 119 |
-
log.info("adapter_detected", job_id=job_id, format=fmt_name, task=task)
|
| 120 |
-
await ds_reg.update_job(job_id, progress=0.60, message=f"Parsing {fmt_name.upper()} {task.upper()}")
|
| 121 |
-
|
| 122 |
-
# Stage 4 β Parse Metadata & Annotations (Streaming)
|
| 123 |
-
class_names = await asyncio.to_thread(adapter.get_class_names, extract_dir)
|
| 124 |
-
image_records = []
|
| 125 |
-
all_annotations = []
|
| 126 |
-
|
| 127 |
-
# Health metrics tracking
|
| 128 |
-
hashes = {} # hash -> filename
|
| 129 |
-
duplicates = 0
|
| 130 |
-
empty_images = 0
|
| 131 |
-
total_ann_count = 0
|
| 132 |
-
|
| 133 |
-
for img_rec, anns in adapter.iter_items(req.dataset_id, extract_dir):
|
| 134 |
-
# Duplicate detection via MD5 hash
|
| 135 |
-
abs_path = extract_dir / img_rec["rel_path"]
|
| 136 |
-
if abs_path.exists():
|
| 137 |
-
img_hash = _calculate_hash(abs_path)
|
| 138 |
-
if img_hash in hashes:
|
| 139 |
-
duplicates += 1
|
| 140 |
-
img_rec["metadata"] = json.dumps({"is_duplicate": True, "original": hashes[img_hash]})
|
| 141 |
-
else:
|
| 142 |
-
hashes[img_hash] = img_rec["filename"]
|
| 143 |
-
|
| 144 |
-
if not anns:
|
| 145 |
-
empty_images += 1
|
| 146 |
-
|
| 147 |
-
total_ann_count += len(anns)
|
| 148 |
-
image_records.append(img_rec)
|
| 149 |
-
all_annotations.extend(anns)
|
| 150 |
-
|
| 151 |
-
if not image_records:
|
| 152 |
-
raise ValueError(f"No valid data files found in {extract_dir}")
|
| 153 |
-
|
| 154 |
-
# Stage 5 β Indexing
|
| 155 |
-
await ds_reg.update_job(job_id, progress=0.80, message=f"Indexing {len(image_records)} items")
|
| 156 |
-
await ds_reg.index_images(req.dataset_id, image_records)
|
| 157 |
-
|
| 158 |
-
if all_annotations:
|
| 159 |
-
await ds_reg.update_job(job_id, progress=0.85, message=f"Indexing {len(all_annotations)} annotations")
|
| 160 |
-
await ds_reg.bulk_insert_annotations(all_annotations)
|
| 161 |
-
|
| 162 |
-
# Stage 6 β Stats & Health Analysis
|
| 163 |
-
size_bytes = await asyncio.to_thread(_dir_size, extract_dir)
|
| 164 |
-
|
| 165 |
-
# Calculate Health Score (0-100)
|
| 166 |
-
# Factors: duplicates, empty images (for detection), class balance (TODO)
|
| 167 |
-
score = 100.0
|
| 168 |
-
if len(image_records) > 0:
|
| 169 |
-
dup_penalty = (duplicates / len(image_records)) * 50
|
| 170 |
-
empty_penalty = (empty_images / len(image_records)) * 20 if task == DatasetTask.detection else 0
|
| 171 |
-
score = max(0.0, 100.0 - dup_penalty - empty_penalty)
|
| 172 |
-
|
| 173 |
-
stats_payload = {
|
| 174 |
-
"image_count": len(image_records),
|
| 175 |
-
"annotation_count": total_ann_count,
|
| 176 |
-
"class_count": len(class_names),
|
| 177 |
-
"empty_images": empty_images,
|
| 178 |
-
"duplicate_count": duplicates,
|
| 179 |
-
"health_score": round(score, 1),
|
| 180 |
-
"avg_objects": round(total_ann_count / len(image_records), 2) if image_records else 0
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
await ds_reg.update_dataset_stats(
|
| 184 |
-
req.dataset_id,
|
| 185 |
-
len(image_records),
|
| 186 |
-
len(class_names),
|
| 187 |
-
class_names,
|
| 188 |
-
size_bytes,
|
| 189 |
-
stats=stats_payload
|
| 190 |
-
)
|
| 191 |
-
await ds_reg.update_dataset_task(req.dataset_id, task)
|
| 192 |
-
|
| 193 |
-
# Cleanup temp zip if applicable
|
| 194 |
-
if source_path.is_file() and source_path.suffix.lower() == ".zip" and "_tmp" in str(source_path):
|
| 195 |
-
source_path.unlink(missing_ok=True)
|
| 196 |
-
|
| 197 |
-
# Stage 7 β Project Linking (Integration point)
|
| 198 |
-
local_path = str(extract_dir)
|
| 199 |
-
from projects.service import link_dataset_to_active_project
|
| 200 |
-
project_ds_root = await link_dataset_to_active_project(req.dataset_id, local_path)
|
| 201 |
-
final_local_path = str(project_ds_root) if project_ds_root and project_ds_root.exists() else local_path
|
| 202 |
-
|
| 203 |
-
# Completion
|
| 204 |
-
await ds_reg.update_job(
|
| 205 |
-
job_id, status="completed", progress=1.0,
|
| 206 |
-
message="Import complete", ended_at=datetime.utcnow().isoformat(),
|
| 207 |
-
)
|
| 208 |
-
await ds_reg.update_dataset_status(req.dataset_id, DatasetStatus.imported, progress=1.0, local_path=final_local_path)
|
| 209 |
-
await audit("dataset_import_complete", {"job_id": job_id, "path": final_local_path}, job_id=job_id)
|
| 210 |
-
log.info("import_complete", job_id=job_id, dataset_id=req.dataset_id)
|
| 211 |
-
|
| 212 |
-
except asyncio.CancelledError:
|
| 213 |
-
await _fail_job(job_id, req.dataset_id, "Import cancelled by user or system")
|
| 214 |
-
raise
|
| 215 |
-
except Exception as exc:
|
| 216 |
-
log.error("import_failed", job_id=job_id, error=str(exc))
|
| 217 |
-
await _fail_job(job_id, req.dataset_id, str(exc))
|
| 218 |
-
await audit("dataset_import_error", {"job_id": job_id, "error": str(exc)}, job_id=job_id, level="error")
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
async def _fail_job(job_id: str, dataset_id: str, error: str) -> None:
|
| 222 |
-
await ds_reg.update_job(
|
| 223 |
-
job_id, status="failed", error=error,
|
| 224 |
-
ended_at=datetime.utcnow().isoformat(),
|
| 225 |
-
message="Import failed",
|
| 226 |
-
)
|
| 227 |
-
await ds_reg.update_dataset_status(dataset_id, DatasetStatus.failed, progress=0.0)
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
# ββ Stage 1: Acquire source ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
-
|
| 232 |
-
async def _stage_acquire(job_id: str, req: ImportRequest) -> Path:
|
| 233 |
-
"""Resolves the source (Download URL, HF Repo, or Local Path)."""
|
| 234 |
-
await ds_reg.update_job(job_id, progress=0.05, message="Acquiring source...")
|
| 235 |
-
|
| 236 |
-
if req.source in ("roboflow", "roboflow_curl"):
|
| 237 |
-
return await _acquire_roboflow(job_id, req)
|
| 238 |
-
|
| 239 |
-
if req.source == "huggingface":
|
| 240 |
-
return await _acquire_huggingface(job_id, req)
|
| 241 |
-
|
| 242 |
-
if req.source == "local":
|
| 243 |
-
return await _acquire_local(job_id, req)
|
| 244 |
-
|
| 245 |
-
raise ValueError(f"Unsupported source provider: {req.source}")
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
async def _acquire_roboflow(job_id: str, req: ImportRequest) -> Path:
|
| 249 |
-
"""Specialized Roboflow downloader using SDK or direct link."""
|
| 250 |
-
# Attempt SDK first (more reliable for Universe)
|
| 251 |
-
try:
|
| 252 |
-
from roboflow import Roboflow
|
| 253 |
-
api_key = req.roboflow_key or (req.headers.get("Authorization") if req.headers else None)
|
| 254 |
-
if api_key and "Bearer " in str(api_key):
|
| 255 |
-
api_key = api_key.split("Bearer ")[-1].strip()
|
| 256 |
-
|
| 257 |
-
if api_key and req.roboflow_workspace and req.roboflow_project:
|
| 258 |
-
rf = Roboflow(api_key=api_key)
|
| 259 |
-
project = rf.workspace(req.roboflow_workspace).project(req.roboflow_project)
|
| 260 |
-
version_obj = project.version(req.roboflow_version or 1)
|
| 261 |
-
|
| 262 |
-
tmp_target = DATASETS_ROOT / "_tmp" / f"rf-{uuid.uuid4().hex[:8]}"
|
| 263 |
-
await ds_reg.update_job(job_id, progress=0.10, message="Downloading via Roboflow SDK...")
|
| 264 |
-
|
| 265 |
-
# Threaded SDK call
|
| 266 |
-
await asyncio.to_thread(
|
| 267 |
-
version_obj.download,
|
| 268 |
-
_format_to_rf_slug(str(req.format)),
|
| 269 |
-
location=str(tmp_target)
|
| 270 |
-
)
|
| 271 |
-
return tmp_target
|
| 272 |
-
except Exception as e:
|
| 273 |
-
log.warning("roboflow_sdk_fallback", error=str(e))
|
| 274 |
-
|
| 275 |
-
# Fallback to direct HTTP download
|
| 276 |
-
url = req.download_url
|
| 277 |
-
if not url and req.source == "roboflow":
|
| 278 |
-
from adapters.roboflow_adapter import RoboflowAdapter
|
| 279 |
-
url = await RoboflowAdapter.get_download_url(
|
| 280 |
-
api_key=req.roboflow_key,
|
| 281 |
-
workspace=req.roboflow_workspace,
|
| 282 |
-
project_id=req.roboflow_project,
|
| 283 |
-
version=req.roboflow_version,
|
| 284 |
-
export_format=_format_to_rf_slug(str(req.format)),
|
| 285 |
-
)
|
| 286 |
-
|
| 287 |
-
if not url:
|
| 288 |
-
raise ValueError("Could not resolve Roboflow download URL")
|
| 289 |
-
|
| 290 |
-
return await _download_zip(job_id, req.dataset_id, url, req.headers)
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
async def _acquire_huggingface(job_id: str, req: ImportRequest) -> Path:
|
| 294 |
-
if not req.hf_dataset_id:
|
| 295 |
-
raise ValueError("hf_dataset_id is missing")
|
| 296 |
-
|
| 297 |
-
dest_dir = _dataset_path(req.dataset_id)
|
| 298 |
-
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 299 |
-
|
| 300 |
-
await ds_reg.update_job(job_id, progress=0.10, message=f"Cloning {req.hf_dataset_id} from HF...")
|
| 301 |
-
|
| 302 |
-
await asyncio.to_thread(
|
| 303 |
-
snapshot_download,
|
| 304 |
-
repo_id=req.hf_dataset_id,
|
| 305 |
-
repo_type="dataset",
|
| 306 |
-
local_dir=str(dest_dir),
|
| 307 |
-
token=settings.hf_token,
|
| 308 |
-
local_dir_use_symlinks=False
|
| 309 |
-
)
|
| 310 |
-
return dest_dir
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
async def _acquire_local(job_id: str, req: ImportRequest) -> Path:
|
| 314 |
-
if not req.local_path:
|
| 315 |
-
raise ValueError("local_path is missing for local import")
|
| 316 |
-
|
| 317 |
-
path = Path(os.path.normpath(req.local_path.strip().strip('"').strip("'")))
|
| 318 |
-
if not path.exists():
|
| 319 |
-
raise FileNotFoundError(f"Local path does not exist: {path}")
|
| 320 |
-
|
| 321 |
-
return path
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
# ββ Stage 2: Extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 325 |
-
|
| 326 |
-
async def _stage_extract(job_id: str, dataset_id: str, source_path: Path) -> Path:
|
| 327 |
-
dest = _dataset_path(dataset_id)
|
| 328 |
-
dest.mkdir(parents=True, exist_ok=True)
|
| 329 |
-
|
| 330 |
-
if source_path.is_dir():
|
| 331 |
-
if source_path == dest:
|
| 332 |
-
return dest
|
| 333 |
-
await ds_reg.update_job(job_id, progress=0.45, message="Copying local files...")
|
| 334 |
-
await asyncio.to_thread(_copy_dir_contents, source_path, dest)
|
| 335 |
-
return dest
|
| 336 |
-
|
| 337 |
-
# It's a zip
|
| 338 |
-
await ds_reg.update_job(job_id, progress=0.45, message="Extracting archive...")
|
| 339 |
-
await ds_reg.update_dataset_status(dataset_id, DatasetStatus.extracting, progress=0.45)
|
| 340 |
-
await asyncio.to_thread(_safe_extract, source_path, dest)
|
| 341 |
-
return dest
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
# ββ Stage 3: Parsing (Memory-Safe) βββββββββββββββββββββββββββββββββββββββββββ
|
| 345 |
-
|
| 346 |
-
def _heuristic_task_detection(fmt: str, root: Path) -> DatasetTask:
|
| 347 |
-
"""Improved task detection based on file content."""
|
| 348 |
-
if fmt == "csv":
|
| 349 |
-
return DatasetTask.nlp
|
| 350 |
-
|
| 351 |
-
# Check for segmentation in COCO
|
| 352 |
-
if fmt == "coco":
|
| 353 |
-
# Sample first few lines of JSON if possible or check file size
|
| 354 |
-
return DatasetTask.segmentation # Heuristic: most modern COCO use cases
|
| 355 |
-
|
| 356 |
-
if fmt in ("yolo", "voc"):
|
| 357 |
-
return DatasetTask.detection
|
| 358 |
-
|
| 359 |
-
return DatasetTask.classification
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
def _parse_yolo(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
|
| 363 |
-
class_map = YOLOParser.load_class_map(root)
|
| 364 |
-
results = []
|
| 365 |
-
# Generator approach to keep memory low
|
| 366 |
-
for rel_path, image_id, split, anns in YOLOParser.iter_dataset(root, dataset_id, class_map):
|
| 367 |
-
abs_path = root / rel_path
|
| 368 |
-
w, h = _img_dimensions(abs_path)
|
| 369 |
-
img_rec = {
|
| 370 |
-
"id": image_id, "filename": Path(rel_path).name,
|
| 371 |
-
"rel_path": str(rel_path), "width": w, "height": h,
|
| 372 |
-
"split": split, "ann_count": len(anns),
|
| 373 |
-
}
|
| 374 |
-
results.append((img_rec, anns))
|
| 375 |
-
return class_map, results
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
def _parse_coco(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
|
| 379 |
-
ann_files = COCOParser.find_annotation_files(root)
|
| 380 |
-
all_classes: list[str] = []
|
| 381 |
-
results = []
|
| 382 |
-
for ann_file in ann_files:
|
| 383 |
-
classes, coco_results = COCOParser.parse_file(ann_file, dataset_id)
|
| 384 |
-
all_classes = list(dict.fromkeys(all_classes + classes))
|
| 385 |
-
for rel_path, image_id, split, anns in coco_results:
|
| 386 |
-
abs_path = root / rel_path
|
| 387 |
-
w, h = _img_dimensions(abs_path)
|
| 388 |
-
img_rec = {
|
| 389 |
-
"id": image_id, "filename": Path(rel_path).name,
|
| 390 |
-
"rel_path": str(rel_path), "width": w, "height": h,
|
| 391 |
-
"split": split, "ann_count": len(anns),
|
| 392 |
-
}
|
| 393 |
-
results.append((img_rec, anns))
|
| 394 |
-
return all_classes, results
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
def _parse_voc(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
|
| 398 |
-
class_set = set()
|
| 399 |
-
results = []
|
| 400 |
-
for rel_path, image_id, split, w, h, anns in VOCParser.iter_dataset(root, dataset_id):
|
| 401 |
-
img_rec = {
|
| 402 |
-
"id": image_id, "filename": Path(rel_path).name,
|
| 403 |
-
"rel_path": str(rel_path), "width": w, "height": h,
|
| 404 |
-
"split": split, "ann_count": len(anns),
|
| 405 |
-
}
|
| 406 |
-
results.append((img_rec, anns))
|
| 407 |
-
for ann in anns:
|
| 408 |
-
class_set.add(ann["label"])
|
| 409 |
-
return sorted(list(class_set)), results
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
def _parse_csv(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
|
| 413 |
-
all_classes = set()
|
| 414 |
-
results = []
|
| 415 |
-
for csv_path in root.rglob("*.csv"):
|
| 416 |
-
anns = CSVParser.parse_file(csv_path, dataset_id)
|
| 417 |
-
# For CSV, each annotation is a row. We group by text entry id (image_id)
|
| 418 |
-
anns_by_id: Dict[str, List[Dict]] = {}
|
| 419 |
-
for ann in anns:
|
| 420 |
-
all_classes.add(ann["label"])
|
| 421 |
-
anns_by_id.setdefault(ann["image_id"], []).append(ann)
|
| 422 |
-
|
| 423 |
-
for text_id, grouped_anns in anns_by_id.items():
|
| 424 |
-
img_rec = {
|
| 425 |
-
"id": text_id, "filename": csv_path.name,
|
| 426 |
-
"rel_path": str(csv_path.relative_to(root)),
|
| 427 |
-
"width": 0, "height": 0, "split": "train", "ann_count": len(grouped_anns),
|
| 428 |
-
}
|
| 429 |
-
results.append((img_rec, grouped_anns))
|
| 430 |
-
return sorted(list(all_classes)), results
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
def _parse_txt(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
|
| 434 |
-
from datasets.annotation_parser import RoboflowTXTParser
|
| 435 |
-
results = []
|
| 436 |
-
class_set = set()
|
| 437 |
-
|
| 438 |
-
for rel_path, image_id, split, anns in RoboflowTXTParser.iter_dataset(root, dataset_id):
|
| 439 |
-
abs_path = root / rel_path
|
| 440 |
-
w, h = _img_dimensions(abs_path)
|
| 441 |
-
img_rec = {
|
| 442 |
-
"id": image_id, "filename": Path(rel_path).name,
|
| 443 |
-
"rel_path": str(rel_path), "width": w, "height": h,
|
| 444 |
-
"split": split, "ann_count": len(anns),
|
| 445 |
-
}
|
| 446 |
-
results.append((img_rec, anns))
|
| 447 |
-
for ann in anns:
|
| 448 |
-
class_set.add(ann["label"])
|
| 449 |
-
|
| 450 |
-
return sorted(list(class_set)), results
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
def _parse_generic_folder(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
|
| 454 |
-
"""
|
| 455 |
-
Enhanced generic folder parser. Supports:
|
| 456 |
-
1. root/class_name/img.jpg
|
| 457 |
-
2. root/train/class_name/img.jpg
|
| 458 |
-
3. root/images/img.jpg
|
| 459 |
-
"""
|
| 460 |
-
results = []
|
| 461 |
-
class_set = set()
|
| 462 |
-
exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
|
| 463 |
-
|
| 464 |
-
# Structural keywords to ignore as classes
|
| 465 |
-
ignore = {"images", "labels", "train", "val", "test", "validation", "training", "valid", "testing", "unknown", "annotations"}
|
| 466 |
-
|
| 467 |
-
for img_path in sorted(root.rglob("*")):
|
| 468 |
-
if img_path.suffix.lower() not in exts:
|
| 469 |
-
continue
|
| 470 |
-
|
| 471 |
-
rel_path = img_path.relative_to(root)
|
| 472 |
-
parts = rel_path.parts
|
| 473 |
-
|
| 474 |
-
# Heuristic for class detection
|
| 475 |
-
label = "unknown"
|
| 476 |
-
split = "train"
|
| 477 |
-
|
| 478 |
-
# Detect split if first folder is a split keyword
|
| 479 |
-
if parts[0].lower() in ignore and len(parts) > 1:
|
| 480 |
-
if parts[0].lower() in ("train", "training"): split = "train"
|
| 481 |
-
elif parts[0].lower() in ("val", "valid", "validation"): split = "val"
|
| 482 |
-
elif parts[0].lower() in ("test", "testing"): split = "test"
|
| 483 |
-
|
| 484 |
-
# Check if next part is class name
|
| 485 |
-
if len(parts) > 2 and parts[1].lower() not in ignore:
|
| 486 |
-
label = parts[1]
|
| 487 |
-
elif len(parts) > 1 and parts[1].lower() not in ignore:
|
| 488 |
-
label = parts[1]
|
| 489 |
-
elif len(parts) > 1 and parts[0].lower() not in ignore:
|
| 490 |
-
label = parts[0]
|
| 491 |
-
|
| 492 |
-
anns = []
|
| 493 |
-
if label != "unknown":
|
| 494 |
-
class_set.add(label)
|
| 495 |
-
image_id = f"img-{uuid.uuid4().hex[:12]}"
|
| 496 |
-
# Create a virtual annotation for classification
|
| 497 |
-
from datasets.annotation_parser import _make_ann
|
| 498 |
-
anns.append(_make_ann(image_id, dataset_id, label, ann_type="classification"))
|
| 499 |
-
else:
|
| 500 |
-
image_id = f"img-{uuid.uuid4().hex[:12]}"
|
| 501 |
-
|
| 502 |
-
w, h = _img_dimensions(img_path)
|
| 503 |
-
img_rec = {
|
| 504 |
-
"id": image_id,
|
| 505 |
-
"filename": img_path.name,
|
| 506 |
-
"rel_path": str(rel_path),
|
| 507 |
-
"width": w, "height": h,
|
| 508 |
-
"split": split,
|
| 509 |
-
"ann_count": len(anns),
|
| 510 |
-
}
|
| 511 |
-
results.append((img_rec, anns))
|
| 512 |
-
|
| 513 |
-
return sorted(list(class_set)), results
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
# ββ Utilities ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 517 |
-
|
| 518 |
-
async def _download_zip(job_id: str, dataset_id: str, url: str, custom_headers: dict = None) -> Path:
|
| 519 |
-
tmp_dir = DATASETS_ROOT / "_tmp"
|
| 520 |
-
tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 521 |
-
zip_path = tmp_dir / f"{dataset_id}-{uuid.uuid4().hex[:8]}.zip"
|
| 522 |
-
|
| 523 |
-
headers = {
|
| 524 |
-
"User-Agent": "Mozilla/5.0 (MLForge Workbench)",
|
| 525 |
-
"Accept": "application/zip, application/octet-stream, */*",
|
| 526 |
-
}
|
| 527 |
-
if custom_headers: headers.update(custom_headers)
|
| 528 |
-
|
| 529 |
-
async with httpx.AsyncClient(follow_redirects=True, timeout=600.0, headers=headers) as client:
|
| 530 |
-
async with client.stream("GET", url) as resp:
|
| 531 |
-
resp.raise_for_status()
|
| 532 |
-
total = int(resp.headers.get("content-length", 0)) or None
|
| 533 |
-
downloaded = 0
|
| 534 |
-
async with aiofiles.open(zip_path, "wb") as f:
|
| 535 |
-
async for chunk in resp.aiter_bytes(chunk_size=settings.download_chunk_size):
|
| 536 |
-
await f.write(chunk)
|
| 537 |
-
downloaded += len(chunk)
|
| 538 |
-
if total:
|
| 539 |
-
pct = 0.10 + (downloaded / total) * 0.35 # 10% -> 45%
|
| 540 |
-
await ds_reg.update_job(job_id, progress=round(pct, 3), message=f"Downloading: {_fmt_bytes(downloaded)} / {_fmt_bytes(total)}")
|
| 541 |
-
|
| 542 |
-
return zip_path
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
def _safe_extract(zip_path: Path, dest: Path) -> None:
|
| 546 |
-
with zipfile.ZipFile(str(zip_path), "r") as zf:
|
| 547 |
-
for member in zf.namelist():
|
| 548 |
-
if os.path.isabs(member) or ".." in Path(member).parts: continue
|
| 549 |
-
zf.extract(member, str(dest))
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
def _copy_dir_contents(src: Path, dest: Path) -> None:
|
| 553 |
-
for item in src.iterdir():
|
| 554 |
-
s, d = src / item.name, dest / item.name
|
| 555 |
-
if s.is_dir(): shutil.copytree(s, d, dirs_exist_ok=True)
|
| 556 |
-
else: shutil.copy2(s, d)
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
def _scan_images_generic(dataset_id: str, root: Path) -> list[dict]:
|
| 560 |
-
records = []
|
| 561 |
-
exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
|
| 562 |
-
for img_path in sorted(root.rglob("*")):
|
| 563 |
-
if img_path.suffix.lower() in exts:
|
| 564 |
-
w, h = _img_dimensions(img_path)
|
| 565 |
-
records.append({
|
| 566 |
-
"id": f"img-{uuid.uuid4().hex[:12]}",
|
| 567 |
-
"filename": img_path.name,
|
| 568 |
-
"rel_path": str(img_path.relative_to(root)),
|
| 569 |
-
"width": w, "height": h, "split": "train", "ann_count": 0,
|
| 570 |
-
})
|
| 571 |
-
return records
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
def _dir_size(path: Path) -> int:
|
| 575 |
-
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
def _fmt_bytes(n: int) -> str:
|
| 579 |
-
for unit in ("B", "KB", "MB", "GB", "TB"):
|
| 580 |
-
if n < 1024: return f"{n:.1f} {unit}"
|
| 581 |
-
n /= 1024
|
| 582 |
-
return f"{n:.1f} PB"
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
def _format_to_rf_slug(fmt: str) -> str:
|
| 586 |
-
return {"yolo": "yolov8", "coco": "coco", "voc": "voc"}.get(fmt, "yolov8")
|
| 587 |
-
|
| 588 |
-
def _format_to_rf_slug(fmt: str) -> str:
|
| 589 |
-
return {"yolo": "yolov8", "coco": "coco", "voc": "voc"}.get(fmt, "yolov8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
datasets/viewer_service.py
DELETED
|
@@ -1,320 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
datasets/viewer_service.py β Dataset Viewer Service.
|
| 3 |
-
|
| 4 |
-
Provides paginated image + annotation serving for the Dataset Viewer UI.
|
| 5 |
-
All paths are resolved relative to the dataset's local_path for security.
|
| 6 |
-
"""
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
from pathlib import Path
|
| 10 |
-
|
| 11 |
-
from datasets import registry as ds_reg
|
| 12 |
-
from models.dataset import (
|
| 13 |
-
Annotation, AnnotationType, BoundingBox, Dataset,
|
| 14 |
-
ImageRecord, ViewerPage, DatasetFormat
|
| 15 |
-
)
|
| 16 |
-
from datasets.annotation_parser import YOLOParser, COCOParser, VOCParser, CSVParser
|
| 17 |
-
from observability.logger import get_logger
|
| 18 |
-
|
| 19 |
-
log = get_logger("viewer_service")
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
from .format_adapters import NLPAdapter, TabularAdapter
|
| 23 |
-
from models.dataset import UniversalViewerPage, UniversalDatasetItem, UniversalAnnotation, DatasetContentType, DatasetTask
|
| 24 |
-
|
| 25 |
-
async def get_universal_viewer_page(
|
| 26 |
-
dataset_id: str,
|
| 27 |
-
page: int = 0,
|
| 28 |
-
page_size: int = 20,
|
| 29 |
-
split: str | None = None,
|
| 30 |
-
class_label: str | None = None,
|
| 31 |
-
) -> UniversalViewerPage:
|
| 32 |
-
"""Polymorphic viewer endpoint that adapts based on dataset task."""
|
| 33 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 34 |
-
if not ds:
|
| 35 |
-
raise ValueError("Dataset not found")
|
| 36 |
-
|
| 37 |
-
ds_root = Path(ds.local_path) if ds.local_path else None
|
| 38 |
-
|
| 39 |
-
# 1. Vision Tasks (Detection, Seg, Pose) -> Use existing image-centric logic
|
| 40 |
-
if ds.task in (DatasetTask.detection, DatasetTask.segmentation, DatasetTask.keypoints):
|
| 41 |
-
# We wrap the existing get_viewer_page and transform to UniversalDatasetItem
|
| 42 |
-
old_page = await get_viewer_page(dataset_id, page, page_size, split, class_label)
|
| 43 |
-
|
| 44 |
-
items = []
|
| 45 |
-
for img in old_page.images:
|
| 46 |
-
items.append(UniversalDatasetItem(
|
| 47 |
-
id=img.image_id,
|
| 48 |
-
content_type=DatasetContentType.image,
|
| 49 |
-
filename=img.filename,
|
| 50 |
-
metadata={"width": img.width, "height": img.height, "split": img.split},
|
| 51 |
-
annotations=[
|
| 52 |
-
UniversalAnnotation(
|
| 53 |
-
label=ann.label,
|
| 54 |
-
type=ann.type.value if hasattr(ann.type, 'value') else str(ann.type),
|
| 55 |
-
bbox=[ann.bbox.x, ann.bbox.y, ann.bbox.width, ann.bbox.height] if ann.bbox else None,
|
| 56 |
-
segmentation=ann.segmentation,
|
| 57 |
-
keypoints=ann.keypoints,
|
| 58 |
-
confidence=ann.confidence,
|
| 59 |
-
metadata=ann.metadata
|
| 60 |
-
) for ann in img.annotations
|
| 61 |
-
]
|
| 62 |
-
))
|
| 63 |
-
|
| 64 |
-
return UniversalViewerPage(
|
| 65 |
-
dataset_id=dataset_id,
|
| 66 |
-
page=page,
|
| 67 |
-
page_size=page_size,
|
| 68 |
-
total=old_page.total,
|
| 69 |
-
total_pages=old_page.total_pages,
|
| 70 |
-
items=items
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
# 2. NLP Tasks (CSV, JSONL)
|
| 74 |
-
elif ds.task == DatasetTask.nlp and ds_root:
|
| 75 |
-
adapter = NLPAdapter()
|
| 76 |
-
total, items = await adapter.get_items(ds_root, page, page_size)
|
| 77 |
-
total_pages = max(1, (total + page_size - 1) // page_size)
|
| 78 |
-
return UniversalViewerPage(
|
| 79 |
-
dataset_id=dataset_id,
|
| 80 |
-
page=page,
|
| 81 |
-
page_size=page_size,
|
| 82 |
-
total=total,
|
| 83 |
-
total_pages=total_pages,
|
| 84 |
-
items=items
|
| 85 |
-
)
|
| 86 |
-
|
| 87 |
-
# 3. Tabular Tasks (CSV, Parquet)
|
| 88 |
-
elif ds.task == DatasetTask.tabular and ds_root:
|
| 89 |
-
adapter = TabularAdapter()
|
| 90 |
-
total, items = await adapter.get_items(ds_root, page, page_size)
|
| 91 |
-
total_pages = max(1, (total + page_size - 1) // page_size)
|
| 92 |
-
return UniversalViewerPage(
|
| 93 |
-
dataset_id=dataset_id,
|
| 94 |
-
page=page,
|
| 95 |
-
page_size=page_size,
|
| 96 |
-
total=total,
|
| 97 |
-
total_pages=total_pages,
|
| 98 |
-
items=items
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
# Fallback / Empty
|
| 102 |
-
return UniversalViewerPage(
|
| 103 |
-
dataset_id=dataset_id,
|
| 104 |
-
page=page,
|
| 105 |
-
page_size=page_size,
|
| 106 |
-
total=0,
|
| 107 |
-
total_pages=0,
|
| 108 |
-
items=[]
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
async def get_viewer_page(
|
| 112 |
-
dataset_id: str,
|
| 113 |
-
page: int = 0,
|
| 114 |
-
page_size: int = 20,
|
| 115 |
-
split: str | None = None,
|
| 116 |
-
class_label: str | None = None,
|
| 117 |
-
) -> ViewerPage:
|
| 118 |
-
"""
|
| 119 |
-
Return a paginated viewer page for the dataset.
|
| 120 |
-
Images come from the index; annotations are loaded per-image.
|
| 121 |
-
"""
|
| 122 |
-
if page_size > 100:
|
| 123 |
-
page_size = 100 # cap to prevent huge payloads
|
| 124 |
-
|
| 125 |
-
total, image_rows = await ds_reg.get_image_page(dataset_id, page, page_size, split, class_label)
|
| 126 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 127 |
-
|
| 128 |
-
# Check if we have an active project and if the dataset exists there
|
| 129 |
-
from projects.service import get_active_project_path
|
| 130 |
-
project_path = await get_active_project_path()
|
| 131 |
-
|
| 132 |
-
# Dynamically load annotations from database first, fallback to filesystem if needed
|
| 133 |
-
image_ids = [row["id"] for row in image_rows]
|
| 134 |
-
dynamic_anns: dict[str, list[Annotation]] = {img_id: [] for img_id in image_ids}
|
| 135 |
-
|
| 136 |
-
# 1. Try loading from DB index (Authoritative for analytics)
|
| 137 |
-
try:
|
| 138 |
-
from database.connection import get_db
|
| 139 |
-
db = await get_db()
|
| 140 |
-
# Fetch all annotations for these images in one go
|
| 141 |
-
placeholders = ",".join(["?"] * len(image_ids))
|
| 142 |
-
async with db.execute(
|
| 143 |
-
f"SELECT * FROM dataset_annotations WHERE image_id IN ({placeholders})",
|
| 144 |
-
image_ids
|
| 145 |
-
) as cur:
|
| 146 |
-
rows = await cur.fetchall()
|
| 147 |
-
for r in rows:
|
| 148 |
-
dynamic_anns[r["image_id"]].append(_row_to_annotation(dict(r)))
|
| 149 |
-
except Exception as e:
|
| 150 |
-
log.warning("db_annotation_read_failed", error=str(e), dataset_id=dataset_id)
|
| 151 |
-
|
| 152 |
-
# 2. Fallback to filesystem if no annotations found in DB and we have a path
|
| 153 |
-
# This maintains compatibility with old datasets or specific live-read needs
|
| 154 |
-
if all(not anns for anns in dynamic_anns.values()) and ds and ds.local_path:
|
| 155 |
-
ds_root = Path(ds.local_path)
|
| 156 |
-
# Use ds.local_path directly as it is now authoritative project-local path
|
| 157 |
-
# Fallback to global removed per user request
|
| 158 |
-
|
| 159 |
-
fmt = ds.format.value if hasattr(ds.format, 'value') else str(ds.format)
|
| 160 |
-
|
| 161 |
-
try:
|
| 162 |
-
if fmt == DatasetFormat.yolo.value or fmt == "yolo":
|
| 163 |
-
class_map = YOLOParser.load_class_map(ds_root)
|
| 164 |
-
for row in image_rows:
|
| 165 |
-
rel_path = Path(row["rel_path"])
|
| 166 |
-
# For YOLO, the label file is usually in a parallel 'labels' folder
|
| 167 |
-
# or in the same folder as the image.
|
| 168 |
-
# Roboflow structure: train/images/img.jpg -> train/labels/img.txt
|
| 169 |
-
parts = list(rel_path.parts)
|
| 170 |
-
|
| 171 |
-
label_rel = None
|
| 172 |
-
if "images" in parts:
|
| 173 |
-
idx = parts.index("images")
|
| 174 |
-
parts_labels = list(parts)
|
| 175 |
-
parts_labels[idx] = "labels"
|
| 176 |
-
label_rel = Path(*parts_labels).with_suffix(".txt")
|
| 177 |
-
|
| 178 |
-
# Fallback: same folder
|
| 179 |
-
label_same_folder = rel_path.with_suffix(".txt")
|
| 180 |
-
|
| 181 |
-
for cand_rel in [label_rel, label_same_folder]:
|
| 182 |
-
if not cand_rel: continue
|
| 183 |
-
label_file = ds_root / cand_rel
|
| 184 |
-
if label_file.exists():
|
| 185 |
-
anns = YOLOParser.parse_file(label_file, row["id"], ds.id, class_map)
|
| 186 |
-
dynamic_anns[row["id"]] = [_row_to_annotation(a) for a in anns]
|
| 187 |
-
break
|
| 188 |
-
|
| 189 |
-
elif fmt == DatasetFormat.coco.value or fmt == "coco":
|
| 190 |
-
jsons = COCOParser.find_annotation_files(ds_root)
|
| 191 |
-
img_map = {row["filename"]: row["id"] for row in image_rows}
|
| 192 |
-
for jf in jsons:
|
| 193 |
-
_, parsed = COCOParser.parse_file(jf, ds.id)
|
| 194 |
-
for p_rel, _, _, anns in parsed:
|
| 195 |
-
fname = Path(p_rel).name
|
| 196 |
-
if fname in img_map:
|
| 197 |
-
img_id = img_map[fname]
|
| 198 |
-
dynamic_anns[img_id].extend([_row_to_annotation(a) for a in anns])
|
| 199 |
-
|
| 200 |
-
elif fmt == DatasetFormat.voc.value or fmt == "voc":
|
| 201 |
-
for row in image_rows:
|
| 202 |
-
img_abs = ds_root / row["rel_path"]
|
| 203 |
-
xml_candidates = [img_abs.with_suffix(".xml")]
|
| 204 |
-
parts = list(Path(row["rel_path"]).parts)
|
| 205 |
-
if "JPEGImages" in parts:
|
| 206 |
-
idx = parts.index("JPEGImages")
|
| 207 |
-
parts[idx] = "Annotations"
|
| 208 |
-
xml_candidates.append(ds_root.joinpath(*parts).with_suffix(".xml"))
|
| 209 |
-
|
| 210 |
-
for cand in xml_candidates:
|
| 211 |
-
if cand.exists():
|
| 212 |
-
_, _, _, anns = VOCParser.parse_file(cand, row["id"], ds.id)
|
| 213 |
-
dynamic_anns[row["id"]] = [_row_to_annotation(a) for a in anns]
|
| 214 |
-
break
|
| 215 |
-
|
| 216 |
-
elif fmt == "csv":
|
| 217 |
-
for row in image_rows:
|
| 218 |
-
csv_path = ds_root / row["rel_path"]
|
| 219 |
-
if csv_path.exists():
|
| 220 |
-
# For CSV/NLP, we might need a more specific way to find the exact row,
|
| 221 |
-
# but for now we reload the file or use a cached version.
|
| 222 |
-
# Since get_viewer_page is paginated, we'll parse the file.
|
| 223 |
-
anns = CSVParser.parse_file(csv_path, ds.id)
|
| 224 |
-
# Find the annotation matching this "image_id" (which is the text entry id)
|
| 225 |
-
matching_anns = [a for a in anns if a["image_id"] == row["id"]]
|
| 226 |
-
dynamic_anns[row["id"]] = [_row_to_annotation(a) for a in matching_anns]
|
| 227 |
-
|
| 228 |
-
except Exception as e:
|
| 229 |
-
log.error("dynamic_annotation_read_failed", error=str(e), dataset_id=dataset_id)
|
| 230 |
-
|
| 231 |
-
images: list[ImageRecord] = []
|
| 232 |
-
for row in image_rows:
|
| 233 |
-
annotations = dynamic_anns.get(row["id"], [])
|
| 234 |
-
images.append(ImageRecord(
|
| 235 |
-
image_id = row["id"],
|
| 236 |
-
filename = row["filename"],
|
| 237 |
-
width = row["width"],
|
| 238 |
-
height = row["height"],
|
| 239 |
-
path = row["rel_path"],
|
| 240 |
-
annotations = annotations,
|
| 241 |
-
split = row["split"],
|
| 242 |
-
))
|
| 243 |
-
|
| 244 |
-
total_pages = max(1, (total + page_size - 1) // page_size)
|
| 245 |
-
|
| 246 |
-
return ViewerPage(
|
| 247 |
-
dataset_id = dataset_id,
|
| 248 |
-
page = page,
|
| 249 |
-
page_size = page_size,
|
| 250 |
-
total = total,
|
| 251 |
-
total_pages = total_pages,
|
| 252 |
-
images = images,
|
| 253 |
-
)
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
def _row_to_annotation(row: dict) -> Annotation:
|
| 257 |
-
bbox = None
|
| 258 |
-
if row.get("bbox_x") is not None:
|
| 259 |
-
bbox = BoundingBox(
|
| 260 |
-
x = row["bbox_x"],
|
| 261 |
-
y = row["bbox_y"],
|
| 262 |
-
width = row["bbox_w"],
|
| 263 |
-
height = row["bbox_h"],
|
| 264 |
-
normalised = bool(row.get("normalised", 1)),
|
| 265 |
-
)
|
| 266 |
-
|
| 267 |
-
segmentation = None
|
| 268 |
-
if row.get("segmentation"):
|
| 269 |
-
try:
|
| 270 |
-
import json
|
| 271 |
-
segmentation = json.loads(row["segmentation"])
|
| 272 |
-
except:
|
| 273 |
-
pass
|
| 274 |
-
|
| 275 |
-
return Annotation(
|
| 276 |
-
label = row["label"],
|
| 277 |
-
bbox = bbox,
|
| 278 |
-
segmentation = segmentation,
|
| 279 |
-
confidence = row.get("confidence"),
|
| 280 |
-
area = row.get("area"),
|
| 281 |
-
type = AnnotationType(row.get("ann_type", "detection")),
|
| 282 |
-
)
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
async def resolve_image_path(dataset_id: str, image_id: str) -> Path | None:
|
| 286 |
-
"""
|
| 287 |
-
Resolve the absolute filesystem path for an image.
|
| 288 |
-
Prioritizes the active project's dataset folder, falling back to the global cache.
|
| 289 |
-
Returns None if dataset not imported or image not found.
|
| 290 |
-
"""
|
| 291 |
-
ds = await ds_reg.get_dataset(dataset_id)
|
| 292 |
-
if ds is None or not ds.local_path:
|
| 293 |
-
return None
|
| 294 |
-
|
| 295 |
-
base_root = Path(ds.local_path)
|
| 296 |
-
# ds.local_path is now authoritative project-local path
|
| 297 |
-
# Fallback removed per user request
|
| 298 |
-
|
| 299 |
-
from database.connection import get_db
|
| 300 |
-
db = await get_db()
|
| 301 |
-
async with db.execute(
|
| 302 |
-
"SELECT rel_path FROM dataset_images WHERE id=? AND dataset_id=?",
|
| 303 |
-
(image_id, dataset_id),
|
| 304 |
-
) as cur:
|
| 305 |
-
row = await cur.fetchone()
|
| 306 |
-
if not row:
|
| 307 |
-
return None
|
| 308 |
-
|
| 309 |
-
abs_path = base_root / row["rel_path"]
|
| 310 |
-
if not abs_path.exists():
|
| 311 |
-
return None
|
| 312 |
-
|
| 313 |
-
# Security: ensure path is under base_root
|
| 314 |
-
try:
|
| 315 |
-
abs_path.resolve().relative_to(base_root.resolve())
|
| 316 |
-
except ValueError:
|
| 317 |
-
log.warning("path_traversal_attempt", dataset_id=dataset_id, image_id=image_id)
|
| 318 |
-
return None
|
| 319 |
-
|
| 320 |
-
return abs_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
models/__pycache__/analytics.cpython-310.pyc
ADDED
|
Binary file (2.27 kB). View file
|
|
|
models/project.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
"""models/project.py β Pydantic domain model for workspace projects."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
from pydantic import BaseModel
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class Project(BaseModel):
|
| 9 |
-
id: str
|
| 10 |
-
name: str
|
| 11 |
-
path: str
|
| 12 |
-
created_at: str
|
| 13 |
-
last_opened: str
|
| 14 |
-
status: str = "idle"
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
projects/__init__.py
ADDED
|
File without changes
|
projects/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (143 Bytes). View file
|
|
|
projects/__pycache__/registry.cpython-310.pyc
ADDED
|
Binary file (2.76 kB). View file
|
|
|
projects/__pycache__/service.cpython-310.pyc
ADDED
|
Binary file (5.61 kB). View file
|
|
|
projects/registry.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""projects/registry.py β Project persistence in SQLite."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from database.connection import get_db
|
| 8 |
+
from models.project import Project
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def upsert_project(project: Project) -> None:
|
| 12 |
+
db = await get_db()
|
| 13 |
+
await db.execute(
|
| 14 |
+
"""INSERT INTO projects (id, name, path, created_at, last_opened, status)
|
| 15 |
+
VALUES (?,?,?,?,?,?)
|
| 16 |
+
ON CONFLICT(id) DO UPDATE SET
|
| 17 |
+
name=excluded.name,
|
| 18 |
+
path=excluded.path,
|
| 19 |
+
last_opened=excluded.last_opened,
|
| 20 |
+
status=excluded.status
|
| 21 |
+
""",
|
| 22 |
+
(
|
| 23 |
+
project.id,
|
| 24 |
+
project.name,
|
| 25 |
+
project.path,
|
| 26 |
+
project.created_at,
|
| 27 |
+
project.last_opened,
|
| 28 |
+
project.status,
|
| 29 |
+
),
|
| 30 |
+
)
|
| 31 |
+
await db.commit()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def list_projects(limit: int = 200, offset: int = 0) -> list[Project]:
|
| 35 |
+
db = await get_db()
|
| 36 |
+
async with db.execute(
|
| 37 |
+
"""SELECT id, name, path, created_at, last_opened, status
|
| 38 |
+
FROM projects
|
| 39 |
+
ORDER BY datetime(last_opened) DESC
|
| 40 |
+
LIMIT ? OFFSET ?
|
| 41 |
+
""",
|
| 42 |
+
(limit, offset),
|
| 43 |
+
) as cur:
|
| 44 |
+
rows = await cur.fetchall()
|
| 45 |
+
return [Project(**dict(r)) for r in rows]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def delete_project(project_id: str) -> bool:
|
| 49 |
+
db = await get_db()
|
| 50 |
+
cur = await db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
| 51 |
+
await db.commit()
|
| 52 |
+
return (cur.rowcount or 0) > 0
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def get_project(project_id: str) -> Project | None:
|
| 56 |
+
db = await get_db()
|
| 57 |
+
async with db.execute(
|
| 58 |
+
"SELECT id, name, path, created_at, last_opened, status FROM projects WHERE id = ?",
|
| 59 |
+
(project_id,),
|
| 60 |
+
) as cur:
|
| 61 |
+
row = await cur.fetchone()
|
| 62 |
+
return Project(**dict(row)) if row else None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def touch_last_opened(project_id: str) -> None:
|
| 66 |
+
db = await get_db()
|
| 67 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 68 |
+
await db.execute(
|
| 69 |
+
"UPDATE projects SET last_opened = ? WHERE id = ?",
|
| 70 |
+
(now, project_id),
|
| 71 |
+
)
|
| 72 |
+
await db.commit()
|
| 73 |
+
|
projects/service.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
projects/service.py β Active project session + model workspace linking.
|
| 3 |
+
|
| 4 |
+
Tracks which project is currently open (via the `session` DB table) and
|
| 5 |
+
copies freshly-downloaded model files into the project's models/ folder
|
| 6 |
+
so the benchmark engine and other workspaces can locate them.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import shutil
|
| 12 |
+
import uuid
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
|
| 16 |
+
from database.connection import get_db
|
| 17 |
+
from observability.logger import get_logger, audit
|
| 18 |
+
from models.model import Model, ModelMetrics
|
| 19 |
+
from registry.registry import upsert_model
|
| 20 |
+
|
| 21 |
+
log = get_logger("projects.service")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def import_local_model(
|
| 25 |
+
name: str,
|
| 26 |
+
task: str,
|
| 27 |
+
framework: str,
|
| 28 |
+
source_file_path: str
|
| 29 |
+
) -> Model:
|
| 30 |
+
"""Import a local model file into the active project."""
|
| 31 |
+
project_id = await get_active_project_id()
|
| 32 |
+
project_path = await get_active_project_path()
|
| 33 |
+
|
| 34 |
+
if not project_id or not project_path:
|
| 35 |
+
raise ValueError("No active project found. Please open a project first.")
|
| 36 |
+
|
| 37 |
+
src = Path(source_file_path)
|
| 38 |
+
if not src.exists():
|
| 39 |
+
raise FileNotFoundError(f"Source model file not found: {source_file_path}")
|
| 40 |
+
|
| 41 |
+
# Create destination directory in project
|
| 42 |
+
model_id = f"local-{uuid.uuid4().hex[:12]}"
|
| 43 |
+
dest_dir = Path(project_path) / "models" / model_id
|
| 44 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
|
| 46 |
+
dest_path = dest_dir / src.name
|
| 47 |
+
shutil.copy2(src, dest_path)
|
| 48 |
+
|
| 49 |
+
# Calculate size
|
| 50 |
+
size_bytes = dest_path.stat().st_size
|
| 51 |
+
size_label = f"{size_bytes / (1024*1024):.1f} MB" if size_bytes > 1024*1024 else f"{size_bytes / 1024:.1f} KB"
|
| 52 |
+
|
| 53 |
+
# Create model entry
|
| 54 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 55 |
+
model = Model(
|
| 56 |
+
id=model_id,
|
| 57 |
+
name=name,
|
| 58 |
+
task=task,
|
| 59 |
+
framework=framework,
|
| 60 |
+
source="local",
|
| 61 |
+
provider="Local Import",
|
| 62 |
+
size=size_bytes,
|
| 63 |
+
size_label=size_label,
|
| 64 |
+
local_path=str(dest_path),
|
| 65 |
+
project_id=project_id,
|
| 66 |
+
downloaded=True,
|
| 67 |
+
status="cached",
|
| 68 |
+
created_at=now,
|
| 69 |
+
updated_at=now,
|
| 70 |
+
metrics=ModelMetrics()
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
await upsert_model(model)
|
| 74 |
+
await audit("model_imported_locally", model_id=model_id, payload={"name": name, "project_id": project_id})
|
| 75 |
+
log.info("model_imported_locally", model_id=model_id, name=name, path=str(dest_path))
|
| 76 |
+
|
| 77 |
+
return model
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ββ Session helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
|
| 82 |
+
async def set_active_project(project_id: str, project_path: str) -> None:
|
| 83 |
+
"""Persist the currently open project in the session table."""
|
| 84 |
+
db = await get_db()
|
| 85 |
+
await db.execute(
|
| 86 |
+
"INSERT INTO session (key, value) VALUES ('active_project_id', ?)"
|
| 87 |
+
" ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
| 88 |
+
(project_id,),
|
| 89 |
+
)
|
| 90 |
+
await db.execute(
|
| 91 |
+
"INSERT INTO session (key, value) VALUES ('active_project_path', ?)"
|
| 92 |
+
" ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
| 93 |
+
(project_path,),
|
| 94 |
+
)
|
| 95 |
+
await db.commit()
|
| 96 |
+
log.info("active_project_set", project_id=project_id, path=project_path)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def get_active_project_id() -> str | None:
|
| 100 |
+
"""Return the ID of the currently open project, or None."""
|
| 101 |
+
db = await get_db()
|
| 102 |
+
async with db.execute(
|
| 103 |
+
"SELECT value FROM session WHERE key = 'active_project_id'"
|
| 104 |
+
) as cur:
|
| 105 |
+
row = await cur.fetchone()
|
| 106 |
+
return row["value"] if row else None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def get_active_project_path() -> str | None:
|
| 110 |
+
"""Return the filesystem path of the currently open project, or None."""
|
| 111 |
+
db = await get_db()
|
| 112 |
+
async with db.execute(
|
| 113 |
+
"SELECT value FROM session WHERE key = 'active_project_path'"
|
| 114 |
+
) as cur:
|
| 115 |
+
row = await cur.fetchone()
|
| 116 |
+
return row["value"] if row else None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ββ Workspace model linking βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 120 |
+
|
| 121 |
+
async def link_model_to_active_project(model_id: str, source_path: str) -> None:
|
| 122 |
+
"""Copy the downloaded model file into the active project's models/ folder.
|
| 123 |
+
|
| 124 |
+
This is a best-effort operation β if no project is open, or if the copy
|
| 125 |
+
fails for any reason, we log and continue rather than failing the download.
|
| 126 |
+
"""
|
| 127 |
+
project_path = await get_active_project_path()
|
| 128 |
+
if not project_path:
|
| 129 |
+
log.debug("link_model_skipped_no_project", model_id=model_id)
|
| 130 |
+
return
|
| 131 |
+
|
| 132 |
+
src = Path(source_path)
|
| 133 |
+
if not src.exists():
|
| 134 |
+
log.warning("link_model_source_missing", model_id=model_id, path=source_path)
|
| 135 |
+
return
|
| 136 |
+
|
| 137 |
+
dest_dir = Path(project_path) / "models" / model_id
|
| 138 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 139 |
+
dest = dest_dir / src.name
|
| 140 |
+
|
| 141 |
+
if dest.exists():
|
| 142 |
+
log.debug("link_model_already_exists", model_id=model_id, dest=str(dest))
|
| 143 |
+
return
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
shutil.copy2(src, dest)
|
| 147 |
+
log.info("model_linked_to_project", model_id=model_id, project=project_path, dest=str(dest))
|
| 148 |
+
except OSError as exc:
|
| 149 |
+
log.warning("link_model_copy_failed", model_id=model_id, error=str(exc))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def link_dataset_to_active_project(dataset_id: str, source_path: str) -> None:
|
| 153 |
+
"""Copy the imported dataset folder into the active project's datasets/ folder.
|
| 154 |
+
|
| 155 |
+
This is a best-effort operation β if no project is open, or if the copy
|
| 156 |
+
fails for any reason, we log and continue rather than failing the import.
|
| 157 |
+
"""
|
| 158 |
+
project_path = await get_active_project_path()
|
| 159 |
+
if not project_path:
|
| 160 |
+
log.debug("link_dataset_skipped_no_project", dataset_id=dataset_id)
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
src = Path(source_path)
|
| 164 |
+
if not src.exists():
|
| 165 |
+
log.warning("link_dataset_source_missing", dataset_id=dataset_id, path=source_path)
|
| 166 |
+
return
|
| 167 |
+
|
| 168 |
+
dest_dir = Path(project_path) / "datasets" / dataset_id
|
| 169 |
+
|
| 170 |
+
if dest_dir.exists():
|
| 171 |
+
log.debug("link_dataset_already_exists", dataset_id=dataset_id, dest=str(dest_dir))
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
if src.is_dir():
|
| 176 |
+
shutil.copytree(src, dest_dir, dirs_exist_ok=True)
|
| 177 |
+
else:
|
| 178 |
+
# If it's a file (e.g. zip that wasn't extracted yet), just copy it
|
| 179 |
+
dest_dir.parent.mkdir(parents=True, exist_ok=True)
|
| 180 |
+
shutil.copy2(src, dest_dir)
|
| 181 |
+
|
| 182 |
+
log.info("dataset_linked_to_project", dataset_id=dataset_id, project=project_path, dest=str(dest_dir))
|
| 183 |
+
return dest_dir
|
| 184 |
+
except OSError as exc:
|
| 185 |
+
log.warning("link_dataset_copy_failed", dataset_id=dataset_id, error=str(exc))
|
| 186 |
+
return None
|