feat: initial HSAP platform
Huaxu Sentinel Active Safety Platform with embedded algorithm code, Docker Compose setup, and vendored dataset scaffolds for clone-and-run. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
17
platform/as_platform/data/ingest/__init__.py
Normal file
17
platform/as_platform/data/ingest/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from as_platform.data.ingest.base import IngestContext, IngestAdapter, NormalizedDataset
|
||||
from as_platform.data.ingest.registry import (
|
||||
UnknownFormatError,
|
||||
available_formats,
|
||||
detect_adapter,
|
||||
inspect_uploaded_dataset,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"IngestContext",
|
||||
"IngestAdapter",
|
||||
"NormalizedDataset",
|
||||
"UnknownFormatError",
|
||||
"available_formats",
|
||||
"detect_adapter",
|
||||
"inspect_uploaded_dataset",
|
||||
]
|
||||
46
platform/as_platform/data/ingest/base.py
Normal file
46
platform/as_platform/data/ingest/base.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Data ingest adapter base abstractions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngestContext:
|
||||
project: str
|
||||
task: str | None
|
||||
source_path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedDataset:
|
||||
format_id: str
|
||||
project: str
|
||||
task: str | None
|
||||
source_path: str
|
||||
split_counts: dict[str, int] = field(default_factory=dict)
|
||||
sample_count: int = 0
|
||||
annotation_count: int = 0
|
||||
artifacts: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class IngestAdapter(ABC):
|
||||
"""Adapter interface for task-specific upload formats."""
|
||||
|
||||
format_id: str = "unknown"
|
||||
projects: tuple[str, ...] = ()
|
||||
|
||||
@abstractmethod
|
||||
def can_handle(self, ctx: IngestContext) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def inspect(self, ctx: IngestContext) -> NormalizedDataset:
|
||||
raise NotImplementedError
|
||||
88
platform/as_platform/data/ingest/dms_coco.py
Normal file
88
platform/as_platform/data/ingest/dms_coco.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""DMS COCO-format adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from as_platform.data.ingest.base import IngestAdapter, IngestContext, NormalizedDataset
|
||||
|
||||
COCO_NAMES = ("instances_train.json", "instances_val.json", "instances_test.json", "annotations.json")
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
class DmsCocoAdapter(IngestAdapter):
|
||||
format_id = "dms_coco"
|
||||
projects = ("dms",)
|
||||
|
||||
def _find_coco_files(self, root: Path) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for name in COCO_NAMES:
|
||||
p = root / "annotations" / name
|
||||
if p.is_file():
|
||||
files.append(p)
|
||||
for name in COCO_NAMES:
|
||||
p = root / name
|
||||
if p.is_file():
|
||||
files.append(p)
|
||||
return files
|
||||
|
||||
def can_handle(self, ctx: IngestContext) -> bool:
|
||||
root = ctx.source_path
|
||||
return len(self._find_coco_files(root)) > 0
|
||||
|
||||
def inspect(self, ctx: IngestContext) -> NormalizedDataset:
|
||||
root = ctx.source_path
|
||||
files = self._find_coco_files(root)
|
||||
split_counts = {"train": 0, "val": 0, "test": 0}
|
||||
ann_count = 0
|
||||
categories: set[str] = set()
|
||||
warnings: list[str] = []
|
||||
for f in files:
|
||||
data = _read_json(f)
|
||||
if not data:
|
||||
warnings.append(f"failed to parse {f.name}")
|
||||
continue
|
||||
images = data.get("images") or []
|
||||
anns = data.get("annotations") or []
|
||||
cats = data.get("categories") or []
|
||||
ann_count += len(anns)
|
||||
for c in cats:
|
||||
name = c.get("name")
|
||||
if isinstance(name, str):
|
||||
categories.add(name)
|
||||
lower = f.name.lower()
|
||||
if "train" in lower:
|
||||
split_counts["train"] += len(images)
|
||||
elif "val" in lower:
|
||||
split_counts["val"] += len(images)
|
||||
elif "test" in lower:
|
||||
split_counts["test"] += len(images)
|
||||
else:
|
||||
split_counts["train"] += len(images)
|
||||
|
||||
return NormalizedDataset(
|
||||
format_id=self.format_id,
|
||||
project=ctx.project,
|
||||
task=ctx.task,
|
||||
source_path=str(root),
|
||||
split_counts=split_counts,
|
||||
sample_count=sum(split_counts.values()),
|
||||
annotation_count=ann_count,
|
||||
artifacts=[self._artifact_name(root, f) for f in files],
|
||||
warnings=warnings,
|
||||
extra={"categories": sorted(categories)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _artifact_name(root: Path, path: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(root))
|
||||
except ValueError:
|
||||
return path.name
|
||||
67
platform/as_platform/data/ingest/dms_yolo.py
Normal file
67
platform/as_platform/data/ingest/dms_yolo.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""DMS YOLO-style dataset adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from as_platform.data.ingest.base import IngestAdapter, IngestContext, NormalizedDataset
|
||||
|
||||
IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".JPG", ".JPEG", ".PNG"}
|
||||
|
||||
|
||||
def _count_images(path: Path) -> int:
|
||||
if not path.is_dir():
|
||||
return 0
|
||||
return sum(1 for p in path.rglob("*") if p.is_file() and p.suffix in IMG_EXTS)
|
||||
|
||||
|
||||
def _count_txt(path: Path) -> int:
|
||||
if not path.is_dir():
|
||||
return 0
|
||||
return sum(1 for p in path.rglob("*.txt") if p.is_file())
|
||||
|
||||
|
||||
class DmsYoloAdapter(IngestAdapter):
|
||||
format_id = "dms_yolo"
|
||||
projects = ("dms",)
|
||||
|
||||
def can_handle(self, ctx: IngestContext) -> bool:
|
||||
root = ctx.source_path
|
||||
return (
|
||||
(root / "images").is_dir()
|
||||
and (root / "labels").is_dir()
|
||||
) or (
|
||||
(root / "images" / "train").is_dir()
|
||||
and (root / "labels" / "train").is_dir()
|
||||
)
|
||||
|
||||
def inspect(self, ctx: IngestContext) -> NormalizedDataset:
|
||||
root = ctx.source_path
|
||||
train_images = _count_images(root / "images" / "train")
|
||||
val_images = _count_images(root / "images" / "val")
|
||||
test_images = _count_images(root / "images" / "test")
|
||||
if train_images + val_images + test_images == 0:
|
||||
# fallback single-folder dataset
|
||||
train_images = _count_images(root / "images")
|
||||
train_labels = _count_txt(root / "labels" / "train")
|
||||
val_labels = _count_txt(root / "labels" / "val")
|
||||
test_labels = _count_txt(root / "labels" / "test")
|
||||
if train_labels + val_labels + test_labels == 0:
|
||||
train_labels = _count_txt(root / "labels")
|
||||
|
||||
warnings: list[str] = []
|
||||
if train_images == 0:
|
||||
warnings.append("train split has no images")
|
||||
if train_labels == 0:
|
||||
warnings.append("train split has no labels")
|
||||
|
||||
return NormalizedDataset(
|
||||
format_id=self.format_id,
|
||||
project=ctx.project,
|
||||
task=ctx.task,
|
||||
source_path=str(root),
|
||||
split_counts={"train": train_images, "val": val_images, "test": test_images},
|
||||
sample_count=train_images + val_images + test_images,
|
||||
annotation_count=train_labels + val_labels + test_labels,
|
||||
artifacts=["images/", "labels/"],
|
||||
warnings=warnings,
|
||||
)
|
||||
34
platform/as_platform/data/ingest/lane_lines.py
Normal file
34
platform/as_platform/data/ingest/lane_lines.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Lane .lines.txt adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from as_platform.data.ingest.base import IngestAdapter, IngestContext, NormalizedDataset
|
||||
|
||||
|
||||
class LaneLinesAdapter(IngestAdapter):
|
||||
format_id = "lane_lines"
|
||||
projects = ("lane",)
|
||||
|
||||
def can_handle(self, ctx: IngestContext) -> bool:
|
||||
root = ctx.source_path
|
||||
return any(root.rglob("*.lines.txt"))
|
||||
|
||||
def inspect(self, ctx: IngestContext) -> NormalizedDataset:
|
||||
root = ctx.source_path
|
||||
line_files = list(root.rglob("*.lines.txt"))
|
||||
split_counts = {"train": len(line_files), "val": 0, "test": 0}
|
||||
warnings: list[str] = []
|
||||
if not line_files:
|
||||
warnings.append("no *.lines.txt found")
|
||||
return NormalizedDataset(
|
||||
format_id=self.format_id,
|
||||
project=ctx.project,
|
||||
task=ctx.task,
|
||||
source_path=str(root),
|
||||
split_counts=split_counts,
|
||||
sample_count=len(line_files),
|
||||
annotation_count=len(line_files),
|
||||
artifacts=["*.lines.txt"],
|
||||
warnings=warnings,
|
||||
)
|
||||
48
platform/as_platform/data/ingest/lane_mask.py
Normal file
48
platform/as_platform/data/ingest/lane_mask.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Lane mask + list txt adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from as_platform.data.ingest.base import IngestAdapter, IngestContext, NormalizedDataset
|
||||
|
||||
|
||||
def _line_count(path: Path) -> int:
|
||||
if not path.is_file():
|
||||
return 0
|
||||
try:
|
||||
return sum(1 for _ in path.open(encoding="utf-8", errors="ignore"))
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
class LaneMaskAdapter(IngestAdapter):
|
||||
format_id = "lane_mask"
|
||||
projects = ("lane",)
|
||||
|
||||
def can_handle(self, ctx: IngestContext) -> bool:
|
||||
root = ctx.source_path
|
||||
return (root / "list" / "train_gt.txt").is_file() or (root / "train_val_gt.txt").is_file()
|
||||
|
||||
def inspect(self, ctx: IngestContext) -> NormalizedDataset:
|
||||
root = ctx.source_path
|
||||
train = _line_count(root / "list" / "train_gt.txt")
|
||||
val = _line_count(root / "list" / "val_gt.txt")
|
||||
test = _line_count(root / "list" / "test_gt.txt")
|
||||
if train == 0 and (root / "train_val_gt.txt").is_file():
|
||||
train = _line_count(root / "train_val_gt.txt")
|
||||
|
||||
warnings: list[str] = []
|
||||
if train == 0:
|
||||
warnings.append("train split list is empty")
|
||||
|
||||
return NormalizedDataset(
|
||||
format_id=self.format_id,
|
||||
project=ctx.project,
|
||||
task=ctx.task,
|
||||
source_path=str(root),
|
||||
split_counts={"train": train, "val": val, "test": test},
|
||||
sample_count=train + val + test,
|
||||
annotation_count=train + val + test,
|
||||
artifacts=["list/train_gt.txt", "list/val_gt.txt", "list/test_gt.txt"],
|
||||
warnings=warnings,
|
||||
)
|
||||
49
platform/as_platform/data/ingest/registry.py
Normal file
49
platform/as_platform/data/ingest/registry.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Adapter registry and auto detection for uploaded datasets."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from as_platform.data.ingest.base import IngestAdapter, IngestContext, NormalizedDataset
|
||||
from as_platform.data.ingest.dms_coco import DmsCocoAdapter
|
||||
from as_platform.data.ingest.dms_yolo import DmsYoloAdapter
|
||||
from as_platform.data.ingest.lane_lines import LaneLinesAdapter
|
||||
from as_platform.data.ingest.lane_mask import LaneMaskAdapter
|
||||
|
||||
|
||||
class UnknownFormatError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
ADAPTERS: tuple[IngestAdapter, ...] = (
|
||||
DmsYoloAdapter(),
|
||||
DmsCocoAdapter(),
|
||||
LaneMaskAdapter(),
|
||||
LaneLinesAdapter(),
|
||||
)
|
||||
|
||||
|
||||
def available_formats(project: str) -> list[str]:
|
||||
return [a.format_id for a in ADAPTERS if project in a.projects]
|
||||
|
||||
|
||||
def detect_adapter(ctx: IngestContext) -> IngestAdapter:
|
||||
for adapter in ADAPTERS:
|
||||
if ctx.project not in adapter.projects:
|
||||
continue
|
||||
if adapter.can_handle(ctx):
|
||||
return adapter
|
||||
raise UnknownFormatError(
|
||||
f"unable to detect format for project={ctx.project}, task={ctx.task}, "
|
||||
f"source={ctx.source_path}. supported={available_formats(ctx.project)}"
|
||||
)
|
||||
|
||||
|
||||
def inspect_uploaded_dataset(project: str, task: str | None, source_path: str | Path) -> NormalizedDataset:
|
||||
ctx = IngestContext(project=project, task=task, source_path=Path(source_path).resolve())
|
||||
if not ctx.source_path.exists():
|
||||
raise FileNotFoundError(f"source path not found: {ctx.source_path}")
|
||||
adapter = detect_adapter(ctx)
|
||||
out = adapter.inspect(ctx)
|
||||
# Ensure adapter id is always reflected in output.
|
||||
out.format_id = adapter.format_id
|
||||
return out
|
||||
Reference in New Issue
Block a user