Initial Seg Data Server Net platform
This commit is contained in:
2
backend/app/__init__.py
Normal file
2
backend/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Seg Data Server backend."""
|
||||
|
||||
140
backend/app/catalog.py
Normal file
140
backend/app/catalog.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import settings
|
||||
from .paths import rel
|
||||
|
||||
|
||||
SEGMODEL_ARCHS = [
|
||||
"Unet",
|
||||
"UnetPlusPlus",
|
||||
"FPN",
|
||||
"PSPNet",
|
||||
"DeepLabV3",
|
||||
"DeepLabV3Plus",
|
||||
"Linknet",
|
||||
"MAnet",
|
||||
"PAN",
|
||||
"UPerNet",
|
||||
"Segformer",
|
||||
"DPT",
|
||||
]
|
||||
|
||||
|
||||
YOLO_MODELS = [
|
||||
"YOLOv8n-seg",
|
||||
"YOLOv8s-seg",
|
||||
"YOLOv8m-seg",
|
||||
"YOLOv8l-seg",
|
||||
"YOLOv8x-seg",
|
||||
"YOLOv9c-seg",
|
||||
"YOLOv9e-seg",
|
||||
"YOLO11n-seg",
|
||||
"YOLO11s-seg",
|
||||
"YOLO11m-seg",
|
||||
"YOLO11l-seg",
|
||||
"YOLO11x-seg",
|
||||
"YOLO12-seg",
|
||||
]
|
||||
|
||||
|
||||
TASK_TYPES = [
|
||||
"mock.echo",
|
||||
"system.backup",
|
||||
"dataset.rename",
|
||||
"dataset.to_png",
|
||||
"dataset.resize",
|
||||
"dataset.pair",
|
||||
"dataset.rebuild_labels",
|
||||
"dataset.stack",
|
||||
"dataset.stitch",
|
||||
"dataset.video_frames",
|
||||
"segmodel.train",
|
||||
"segmodel.batch_train",
|
||||
"segmodel.predict",
|
||||
"segmodel.batch_predict",
|
||||
"segmodel.flops",
|
||||
"segmodel.raw_mask_check",
|
||||
"segmodel.metrics",
|
||||
"yolo.train",
|
||||
"yolo.batch_train",
|
||||
"yolo.predict",
|
||||
"yolo.batch_predict",
|
||||
"yolo.heatmap",
|
||||
"yolo.compare",
|
||||
"yolo.raw_mask_check",
|
||||
"yolo.video_visible",
|
||||
"yolo.video_unvisible",
|
||||
"mmseg.init_weights",
|
||||
"mmseg.generate_data",
|
||||
"mmseg.generate_alg",
|
||||
"mmseg.train",
|
||||
"mmseg.metrics",
|
||||
"mmseg.flops_fps",
|
||||
"mmseg.draw",
|
||||
"mmseg.extract_loss_miou",
|
||||
"analysis.all",
|
||||
]
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def discover_datasets() -> list[dict[str, Any]]:
|
||||
root = settings.source_root
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for base in ["DataSet_Public", "BestMode_Predict_Results_DataSet_Public", "Hardisk"]:
|
||||
parent = root / base
|
||||
if not parent.exists():
|
||||
continue
|
||||
for item in sorted(parent.iterdir()):
|
||||
if item.is_dir():
|
||||
candidates.append({"name": item.name, "path": rel(item, root), "source": base})
|
||||
mmseg_params = root / "Seg_All_In_One_MMSeg" / "My_All_In_One" / "1_Data_Parameter"
|
||||
for item in sorted(mmseg_params.glob("*.json")):
|
||||
data = _read_json(item)
|
||||
if item.name == "All_Data_Record.json" or not data:
|
||||
continue
|
||||
candidates.append({"name": item.stem, "path": rel(item, root), "source": "mmseg_parameter"})
|
||||
return candidates
|
||||
|
||||
|
||||
def discover_mmseg_algorithms() -> list[str]:
|
||||
alg_dir = settings.source_root / "Seg_All_In_One_MMSeg" / "My_All_In_One" / "2_Alg_Program"
|
||||
if not alg_dir.exists():
|
||||
return []
|
||||
return sorted(path.stem for path in alg_dir.glob("*.py"))
|
||||
|
||||
|
||||
def discover_weights_summary() -> dict[str, Any]:
|
||||
manifest = settings.weights_root / "manifest.json"
|
||||
if not manifest.exists():
|
||||
return {"manifest": None, "count": 0, "total_bytes": 0}
|
||||
data = _read_json(manifest) or {}
|
||||
return {
|
||||
"manifest": rel(manifest, settings.project_root),
|
||||
"count": len(data.get("files", [])),
|
||||
"total_bytes": data.get("total_bytes", 0),
|
||||
"updated_at": data.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def get_catalog() -> dict[str, Any]:
|
||||
return {
|
||||
"source_root": str(settings.source_root),
|
||||
"project_root": str(settings.project_root),
|
||||
"task_types": TASK_TYPES,
|
||||
"segmodel_architectures": SEGMODEL_ARCHS,
|
||||
"yolo_models": YOLO_MODELS,
|
||||
"mmseg_algorithms": discover_mmseg_algorithms(),
|
||||
"datasets": discover_datasets(),
|
||||
"weights": discover_weights_summary(),
|
||||
}
|
||||
|
||||
45
backend/app/commands.py
Normal file
45
backend/app/commands.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandSpec:
|
||||
command: list[str]
|
||||
cwd: Path
|
||||
description: str = ""
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
stdin_text: str | None = None
|
||||
|
||||
|
||||
TaskFactory = callable
|
||||
|
||||
|
||||
def conda_python(env_name: str, script: Path, *args: object) -> list[str]:
|
||||
return ["conda", "run", "-n", env_name, "python", str(script), *[str(a) for a in args]]
|
||||
|
||||
|
||||
def python(script: Path, *args: object) -> list[str]:
|
||||
return ["python", str(script), *[str(a) for a in args]]
|
||||
|
||||
|
||||
def bash(script: Path, *args: object) -> list[str]:
|
||||
return ["bash", str(script), *[str(a) for a in args]]
|
||||
|
||||
|
||||
def option(params: dict, name: str, default=None):
|
||||
value = params.get(name, default)
|
||||
return value
|
||||
|
||||
|
||||
def required(params: dict, name: str):
|
||||
if name not in params or params[name] in (None, ""):
|
||||
raise ValueError(f"missing required parameter: {name}")
|
||||
return params[name]
|
||||
|
||||
|
||||
def append_flag(args: list[str], flag: str, value):
|
||||
if value not in (None, ""):
|
||||
args.extend([flag, str(value)])
|
||||
|
||||
55
backend/app/config.py
Normal file
55
backend/app/config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _resolve_path(value: str | None, default: Path) -> Path:
|
||||
base = Path(value).expanduser() if value else default
|
||||
if not base.is_absolute():
|
||||
base = default.parent / base
|
||||
return base.resolve()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
project_root: Path
|
||||
source_root: Path
|
||||
db_path: Path
|
||||
log_dir: Path
|
||||
weights_root: Path
|
||||
task_conda_env: str
|
||||
backend_conda_env: str
|
||||
weight_mode: str
|
||||
enable_shell_tasks: bool
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
project_root = Path(os.getenv("SEG_DATA_SERVER_ROOT", Path(__file__).resolve().parents[2])).expanduser()
|
||||
if not project_root.is_absolute():
|
||||
project_root = (Path(__file__).resolve().parents[2] / project_root).resolve()
|
||||
else:
|
||||
project_root = project_root.resolve()
|
||||
|
||||
sibling_source = project_root.parent / "Seg"
|
||||
default_source = sibling_source if sibling_source.exists() else project_root.parent
|
||||
source_root = _resolve_path(os.getenv("SEG_SOURCE_ROOT"), default_source)
|
||||
db_path = _resolve_path(os.getenv("SEG_BACKEND_DB"), project_root / "var" / "seg_data_server.sqlite3")
|
||||
log_dir = _resolve_path(os.getenv("SEG_BACKEND_LOG_DIR"), project_root / "var" / "job_logs")
|
||||
weights_root = (project_root / "weights").resolve()
|
||||
|
||||
return Settings(
|
||||
project_root=project_root,
|
||||
source_root=source_root,
|
||||
db_path=db_path,
|
||||
log_dir=log_dir,
|
||||
weights_root=weights_root,
|
||||
task_conda_env=os.getenv("SEG_TASK_CONDA_ENV", "seg_smp"),
|
||||
backend_conda_env=os.getenv("SEG_BACKEND_CONDA_ENV", "seg_server"),
|
||||
weight_mode=os.getenv("SEG_WEIGHT_MODE", "copy"),
|
||||
enable_shell_tasks=os.getenv("SEG_ENABLE_SHELL_TASKS", "1") == "1",
|
||||
)
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
172
backend/app/db.py
Normal file
172
backend/app/db.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(settings.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
create table if not exists jobs (
|
||||
id text primary key,
|
||||
type text not null,
|
||||
status text not null,
|
||||
params_json text not null,
|
||||
command_json text not null,
|
||||
cwd text not null,
|
||||
description text not null default '',
|
||||
pid integer,
|
||||
exit_code integer,
|
||||
created_at text not null,
|
||||
started_at text,
|
||||
finished_at text,
|
||||
log_path text not null,
|
||||
error text
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
create table if not exists profiles (
|
||||
id integer primary key autoincrement,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
data_json text not null,
|
||||
updated_at text not null,
|
||||
unique(name, kind)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _job_from_row(row: sqlite3.Row) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
data["params"] = json.loads(data.pop("params_json"))
|
||||
data["command"] = json.loads(data.pop("command_json"))
|
||||
return data
|
||||
|
||||
|
||||
def insert_job(job: dict[str, Any]) -> None:
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into jobs (
|
||||
id, type, status, params_json, command_json, cwd, description,
|
||||
pid, exit_code, created_at, started_at, finished_at, log_path, error
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
job["type"],
|
||||
job["status"],
|
||||
json.dumps(job["params"], ensure_ascii=False),
|
||||
json.dumps(job["command"], ensure_ascii=False),
|
||||
job["cwd"],
|
||||
job.get("description", ""),
|
||||
job.get("pid"),
|
||||
job.get("exit_code"),
|
||||
job["created_at"],
|
||||
job.get("started_at"),
|
||||
job.get("finished_at"),
|
||||
job["log_path"],
|
||||
job.get("error"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def update_job(job_id: str, **fields: Any) -> None:
|
||||
if not fields:
|
||||
return
|
||||
allowed = {
|
||||
"status",
|
||||
"pid",
|
||||
"exit_code",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"error",
|
||||
}
|
||||
updates = {key: value for key, value in fields.items() if key in allowed}
|
||||
if not updates:
|
||||
return
|
||||
assignments = ", ".join(f"{key}=?" for key in updates)
|
||||
with connect() as conn:
|
||||
conn.execute(f"update jobs set {assignments} where id=?", [*updates.values(), job_id])
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||
with connect() as conn:
|
||||
row = conn.execute("select * from jobs where id=?", (job_id,)).fetchone()
|
||||
return _job_from_row(row) if row else None
|
||||
|
||||
|
||||
def list_jobs(limit: int = 100) -> list[dict[str, Any]]:
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"select * from jobs order by created_at desc limit ?", (limit,)
|
||||
).fetchall()
|
||||
return [_job_from_row(row) for row in rows]
|
||||
|
||||
|
||||
def upsert_profile(name: str, kind: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
updated_at = utc_now()
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into profiles (name, kind, data_json, updated_at)
|
||||
values (?, ?, ?, ?)
|
||||
on conflict(name, kind) do update set
|
||||
data_json=excluded.data_json,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(name, kind, json.dumps(data, ensure_ascii=False), updated_at),
|
||||
)
|
||||
return {"name": name, "kind": kind, "data": data, "updated_at": updated_at}
|
||||
|
||||
|
||||
def list_profiles(kind: str | None = None) -> list[dict[str, Any]]:
|
||||
sql = "select name, kind, data_json, updated_at from profiles"
|
||||
params: tuple = ()
|
||||
if kind:
|
||||
sql += " where kind=?"
|
||||
params = (kind,)
|
||||
sql += " order by kind, name"
|
||||
with connect() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [
|
||||
{
|
||||
"name": row["name"],
|
||||
"kind": row["kind"],
|
||||
"data": json.loads(row["data_json"]),
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def log_tail(log_path: str | Path, max_bytes: int = 8192) -> str:
|
||||
path = Path(log_path)
|
||||
if not path.exists():
|
||||
return ""
|
||||
size = path.stat().st_size
|
||||
with path.open("rb") as handle:
|
||||
if size > max_bytes:
|
||||
handle.seek(size - max_bytes)
|
||||
return handle.read().decode("utf-8", errors="replace")
|
||||
|
||||
116
backend/app/jobs.py
Normal file
116
backend/app/jobs.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from . import db
|
||||
from .commands import CommandSpec
|
||||
from .config import settings
|
||||
from .modules import build_module_task
|
||||
from .schemas import JobCreate
|
||||
|
||||
_running: dict[str, subprocess.Popen] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _build_task(request: JobCreate) -> CommandSpec:
|
||||
conda_env = request.conda_env or settings.task_conda_env
|
||||
spec = build_module_task(request.type, request.params, conda_env)
|
||||
if spec is None:
|
||||
raise ValueError(f"unsupported job type: {request.type}")
|
||||
env = dict(spec.env)
|
||||
if request.gpus:
|
||||
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(gpu) for gpu in request.gpus)
|
||||
return CommandSpec(
|
||||
command=spec.command,
|
||||
cwd=spec.cwd,
|
||||
description=spec.description,
|
||||
env=env,
|
||||
stdin_text=spec.stdin_text,
|
||||
)
|
||||
|
||||
|
||||
def create_job(request: JobCreate) -> dict:
|
||||
spec = _build_task(request)
|
||||
job_id = uuid.uuid4().hex
|
||||
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = settings.log_dir / f"{job_id}.log"
|
||||
job = {
|
||||
"id": job_id,
|
||||
"type": request.type,
|
||||
"status": "queued",
|
||||
"params": request.params,
|
||||
"command": spec.command,
|
||||
"cwd": str(spec.cwd),
|
||||
"description": spec.description,
|
||||
"pid": None,
|
||||
"exit_code": None,
|
||||
"created_at": db.utc_now(),
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"log_path": str(log_path),
|
||||
"error": None,
|
||||
}
|
||||
db.insert_job(job)
|
||||
thread = threading.Thread(target=_run_job, args=(job_id, spec, log_path), daemon=True)
|
||||
thread.start()
|
||||
return db.get_job(job_id)
|
||||
|
||||
|
||||
def _run_job(job_id: str, spec: CommandSpec, log_path: Path) -> None:
|
||||
env = os.environ.copy()
|
||||
env.update(spec.env)
|
||||
db.update_job(job_id, status="running", started_at=db.utc_now())
|
||||
try:
|
||||
with log_path.open("ab") as log_file:
|
||||
log_file.write(("COMMAND: " + " ".join(spec.command) + "\n").encode("utf-8"))
|
||||
log_file.flush()
|
||||
process = subprocess.Popen(
|
||||
spec.command,
|
||||
cwd=str(spec.cwd),
|
||||
env=env,
|
||||
stdin=subprocess.PIPE if spec.stdin_text is not None else None,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
with _lock:
|
||||
_running[job_id] = process
|
||||
db.update_job(job_id, pid=process.pid)
|
||||
if spec.stdin_text is not None and process.stdin is not None:
|
||||
process.stdin.write(spec.stdin_text.encode("utf-8"))
|
||||
process.stdin.close()
|
||||
exit_code = process.wait()
|
||||
with _lock:
|
||||
_running.pop(job_id, None)
|
||||
current = db.get_job(job_id)
|
||||
if current and current["status"] == "cancelled":
|
||||
db.update_job(job_id, exit_code=exit_code, finished_at=db.utc_now())
|
||||
elif exit_code == 0:
|
||||
db.update_job(job_id, status="success", exit_code=exit_code, finished_at=db.utc_now())
|
||||
else:
|
||||
db.update_job(job_id, status="failed", exit_code=exit_code, finished_at=db.utc_now())
|
||||
except Exception as exc:
|
||||
with _lock:
|
||||
_running.pop(job_id, None)
|
||||
db.update_job(job_id, status="failed", error=str(exc), finished_at=db.utc_now())
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> dict | None:
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
return None
|
||||
db.update_job(job_id, status="cancelled", finished_at=db.utc_now())
|
||||
with _lock:
|
||||
process = _running.get(job_id)
|
||||
if process and process.poll() is None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return db.get_job(job_id)
|
||||
|
||||
172
backend/app/main.py
Normal file
172
backend/app/main.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from . import db
|
||||
from .catalog import get_catalog
|
||||
from .config import settings
|
||||
from .jobs import cancel_job, create_job
|
||||
from .modules.system.service import disk_usage, get_conda_envs, get_gpus, scan_results
|
||||
from .modules.weights.service import load_manifest, sync_weights, verify_weights
|
||||
from .paths import ensure_inside
|
||||
from .schemas import JobCreate, ProfileCreate, WeightSyncRequest
|
||||
|
||||
app = FastAPI(title="Seg Data Server Net", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
db.init_db()
|
||||
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.weights_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"source_root": str(settings.source_root),
|
||||
"project_root": str(settings.project_root),
|
||||
"disk": disk_usage(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/system/gpus")
|
||||
def api_gpus() -> dict:
|
||||
return get_gpus()
|
||||
|
||||
|
||||
@app.get("/api/system/envs")
|
||||
def api_envs() -> dict:
|
||||
return get_conda_envs()
|
||||
|
||||
|
||||
@app.get("/api/catalog")
|
||||
def api_catalog() -> dict:
|
||||
return get_catalog()
|
||||
|
||||
|
||||
@app.get("/api/profiles")
|
||||
def api_profiles(kind: str | None = None) -> list[dict]:
|
||||
return db.list_profiles(kind)
|
||||
|
||||
|
||||
@app.post("/api/profiles")
|
||||
def api_save_profile(profile: ProfileCreate) -> dict:
|
||||
return db.upsert_profile(profile.name, profile.kind, profile.data)
|
||||
|
||||
|
||||
@app.post("/api/jobs")
|
||||
def api_create_job(request: JobCreate) -> dict:
|
||||
try:
|
||||
return create_job(request)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def api_jobs(limit: int = 100) -> list[dict]:
|
||||
return db.list_jobs(limit)
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def api_job(job_id: str) -> dict:
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
job["log_tail"] = db.log_tail(job["log_path"])
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/cancel")
|
||||
def api_cancel_job(job_id: str) -> dict:
|
||||
job = cancel_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}/events")
|
||||
async def api_job_events(job_id: str):
|
||||
async def stream():
|
||||
last_size = 0
|
||||
while True:
|
||||
job = db.get_job(job_id)
|
||||
if not job:
|
||||
yield "event: error\ndata: job not found\n\n"
|
||||
return
|
||||
path = Path(job["log_path"])
|
||||
chunk = ""
|
||||
if path.exists():
|
||||
size = path.stat().st_size
|
||||
if size > last_size:
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(last_size)
|
||||
chunk = handle.read(size - last_size).decode("utf-8", errors="replace")
|
||||
last_size = size
|
||||
payload = {"job": job, "chunk": chunk}
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
if job["status"] in {"success", "failed", "cancelled"}:
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.get("/api/results")
|
||||
def api_results() -> list[dict]:
|
||||
return scan_results()
|
||||
|
||||
|
||||
@app.get("/api/artifacts/{artifact_path:path}")
|
||||
def api_artifact(artifact_path: str):
|
||||
candidate = Path(artifact_path)
|
||||
if not candidate.is_absolute():
|
||||
candidate = settings.source_root / candidate
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
allowed = False
|
||||
for root in (settings.source_root, settings.project_root):
|
||||
try:
|
||||
ensure_inside(resolved, root)
|
||||
allowed = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not allowed:
|
||||
raise ValueError("artifact path is outside allowed roots")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
raise HTTPException(status_code=404, detail="artifact not found")
|
||||
return FileResponse(resolved)
|
||||
|
||||
|
||||
@app.get("/api/weights")
|
||||
def api_weights() -> dict:
|
||||
return load_manifest()
|
||||
|
||||
|
||||
@app.post("/api/weights/sync")
|
||||
def api_weight_sync(request: WeightSyncRequest) -> dict:
|
||||
return sync_weights(request.mode, request.hash_files, request.skip_existing)
|
||||
|
||||
|
||||
@app.post("/api/weights/verify")
|
||||
def api_weight_verify() -> dict:
|
||||
return verify_weights()
|
||||
|
||||
24
backend/app/modules/__init__.py
Normal file
24
backend/app/modules/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .analysis.tasks import build_analysis_task
|
||||
from .dataset.tasks import build_dataset_task
|
||||
from .mmseg.tasks import build_mmseg_task
|
||||
from .segmodel.tasks import build_segmodel_task
|
||||
from .system.tasks import build_system_task
|
||||
from .yolo.tasks import build_yolo_task
|
||||
|
||||
|
||||
def build_module_task(job_type: str, params: dict, conda_env: str):
|
||||
for builder in (
|
||||
build_dataset_task,
|
||||
build_segmodel_task,
|
||||
build_yolo_task,
|
||||
build_mmseg_task,
|
||||
build_analysis_task,
|
||||
build_system_task,
|
||||
):
|
||||
spec = builder(job_type, params, conda_env)
|
||||
if spec is not None:
|
||||
return spec
|
||||
return None
|
||||
|
||||
2
backend/app/modules/analysis/__init__.py
Normal file
2
backend/app/modules/analysis/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Analysis task wrappers."""
|
||||
|
||||
18
backend/app/modules/analysis/tasks.py
Normal file
18
backend/app/modules/analysis/tasks.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...commands import CommandSpec, append_flag, conda_python
|
||||
from ...config import settings
|
||||
|
||||
|
||||
ANALYSIS_DIR = settings.source_root / "Seg_All_In_One_Analysis"
|
||||
|
||||
|
||||
def build_analysis_task(job_type: str, params: dict, conda_env: str) -> CommandSpec | None:
|
||||
if job_type != "analysis.all":
|
||||
return None
|
||||
args = conda_python(conda_env, ANALYSIS_DIR / "1_Analysis_All.py")
|
||||
append_flag(args, "--input_dir", params.get("input_dir", "../BestMode_Predict_Results_DataSet_Public"))
|
||||
append_flag(args, "--output_dir", params.get("output_dir", "./"))
|
||||
stdin = f"{params.get('dataset_choice', 1)}\n"
|
||||
return CommandSpec(args, ANALYSIS_DIR, "merge SegModel/MMSeg metrics and generate plots", stdin_text=stdin)
|
||||
|
||||
2
backend/app/modules/dataset/__init__.py
Normal file
2
backend/app/modules/dataset/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Dataset task wrappers."""
|
||||
|
||||
88
backend/app/modules/dataset/tasks.py
Normal file
88
backend/app/modules/dataset/tasks.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ...commands import CommandSpec, append_flag, bash, conda_python, required
|
||||
from ...config import settings
|
||||
|
||||
|
||||
DATASET_TOOL_DIR = settings.source_root / "DataSet_Own" / "1. 图片预处理(内含使用手册)"
|
||||
STACK_TOOL_DIR = settings.source_root / "Tool-图片堆叠"
|
||||
VIDEO_DIR = settings.source_root / "Seg_Predict_Own_Video_V2"
|
||||
YOLO_DATASET_DIR = settings.source_root / "Seg_All_In_One_YoloModel" / "Yolo数据集构建"
|
||||
|
||||
|
||||
def _dataset_script(name: str) -> Path:
|
||||
return DATASET_TOOL_DIR / name
|
||||
|
||||
|
||||
def build_dataset_task(job_type: str, params: dict, conda_env: str) -> CommandSpec | None:
|
||||
if job_type == "dataset.rename":
|
||||
args = bash(_dataset_script("1_rename_pics.sh"))
|
||||
append_flag(args, "-i", required(params, "image_dir"))
|
||||
append_flag(args, "-l", required(params, "label_dir"))
|
||||
return CommandSpec(args, DATASET_TOOL_DIR, "rename and normalize image/label names")
|
||||
|
||||
if job_type == "dataset.to_png":
|
||||
script = _dataset_script("2_1_Trans_to_png.py")
|
||||
args = conda_python(conda_env, script)
|
||||
append_flag(args, "-i", params.get("input_dir"))
|
||||
append_flag(args, "-o", params.get("output_dir"))
|
||||
return CommandSpec(args, DATASET_TOOL_DIR, "convert images to png")
|
||||
|
||||
if job_type == "dataset.resize":
|
||||
args = bash(_dataset_script("2_reformate_pics.sh"))
|
||||
append_flag(args, "-i", params.get("image_dir"))
|
||||
append_flag(args, "-l", params.get("label_dir"))
|
||||
append_flag(args, "-w", params.get("width", 1920))
|
||||
append_flag(args, "-h", params.get("height", 1080))
|
||||
return CommandSpec(args, DATASET_TOOL_DIR, "resize and reformat image/label folders")
|
||||
|
||||
if job_type == "dataset.pair":
|
||||
args = bash(_dataset_script("3_pair_ori_label.sh"))
|
||||
append_flag(args, "-i", required(params, "image_dir"))
|
||||
append_flag(args, "-l", required(params, "label_dir"))
|
||||
append_flag(args, "-p", params.get("prefix", ""))
|
||||
append_flag(args, "-s", params.get("suffix", ""))
|
||||
return CommandSpec(args, DATASET_TOOL_DIR, "check image and label pairing")
|
||||
|
||||
if job_type == "dataset.rebuild_labels":
|
||||
args = bash(_dataset_script("4_rebuild_labels.sh"))
|
||||
append_flag(args, "-l", required(params, "label_dir"))
|
||||
return CommandSpec(args, DATASET_TOOL_DIR, "rebuild color labels into GT masks")
|
||||
|
||||
if job_type == "dataset.stack":
|
||||
args = bash(_dataset_script("5_TOOL_stack_pics.sh"))
|
||||
append_flag(args, "-i", required(params, "image_dir"))
|
||||
append_flag(args, "-l", required(params, "label_dir"))
|
||||
append_flag(args, "-r", required(params, "result_dir"))
|
||||
append_flag(args, "-a", params.get("alpha", 0.3))
|
||||
append_flag(args, "-p", params.get("prefix", ""))
|
||||
append_flag(args, "-s", params.get("suffix", ""))
|
||||
return CommandSpec(args, DATASET_TOOL_DIR, "overlay image and label for inspection")
|
||||
|
||||
if job_type == "dataset.stitch":
|
||||
args = bash(_dataset_script("6_TOOL_stitch_pics.sh"))
|
||||
append_flag(args, "-i", required(params, "image_dir"))
|
||||
append_flag(args, "-l", required(params, "label_dir"))
|
||||
append_flag(args, "-r", required(params, "result_dir"))
|
||||
return CommandSpec(args, DATASET_TOOL_DIR, "stitch image and label panels")
|
||||
|
||||
if job_type == "dataset.video_frames":
|
||||
script = VIDEO_DIR / "1_Save_Frame_V2.py"
|
||||
args = conda_python(conda_env, script)
|
||||
append_flag(args, "--video", required(params, "video"))
|
||||
append_flag(args, "--interval", params.get("interval", 0.5))
|
||||
append_flag(args, "--resize", params.get("resize"))
|
||||
append_flag(args, "--output_dir", params.get("output_dir"))
|
||||
return CommandSpec(args, VIDEO_DIR, "extract video frames into DataSet_Public layout")
|
||||
|
||||
if job_type == "dataset.yolo_check_pairs":
|
||||
script = YOLO_DATASET_DIR / "0_1_check_picture_pair.py"
|
||||
args = conda_python(conda_env, script)
|
||||
append_flag(args, "-i", required(params, "image_dir"))
|
||||
append_flag(args, "-l", required(params, "label_dir"))
|
||||
return CommandSpec(args, YOLO_DATASET_DIR, "check YOLO image/label pairs")
|
||||
|
||||
return None
|
||||
|
||||
2
backend/app/modules/mmseg/__init__.py
Normal file
2
backend/app/modules/mmseg/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""MMSeg task wrappers."""
|
||||
|
||||
94
backend/app/modules/mmseg/tasks.py
Normal file
94
backend/app/modules/mmseg/tasks.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...commands import CommandSpec, append_flag, conda_python, required
|
||||
from ...config import settings
|
||||
|
||||
|
||||
MMSEG_DIR = settings.source_root / "Seg_All_In_One_MMSeg"
|
||||
MY_DIR = MMSEG_DIR / "My_All_In_One"
|
||||
|
||||
|
||||
def _stdin_for_generate_alg(params: dict) -> str:
|
||||
lines = [
|
||||
str(params.get("dataset_choice", 1)),
|
||||
str(params.get("gpu_count", 1)),
|
||||
]
|
||||
gpu_ids = params.get("gpu_ids", [0])
|
||||
if isinstance(gpu_ids, str):
|
||||
gpu_ids = [part.strip() for part in gpu_ids.split(",") if part.strip()]
|
||||
for index in range(int(params.get("gpu_count", len(gpu_ids) or 1))):
|
||||
lines.append(str(gpu_ids[index] if index < len(gpu_ids) else 0))
|
||||
|
||||
mode = str(params.get("schedule_mode", 2))
|
||||
lines.append(mode)
|
||||
if mode == "1":
|
||||
lines.extend(
|
||||
[
|
||||
str(params.get("train_k", 40)),
|
||||
str(params.get("check_count", 10)),
|
||||
str(params.get("logger_interval", 50)),
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
str(params.get("max_epochs", 300)),
|
||||
str(params.get("val_interval", 1)),
|
||||
str(params.get("checkpoint_interval", 10)),
|
||||
str(params.get("logger_interval", "")),
|
||||
]
|
||||
)
|
||||
lines.append(str(params.get("algorithm_choice", 1)))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def build_mmseg_task(job_type: str, params: dict, conda_env: str) -> CommandSpec | None:
|
||||
if job_type == "mmseg.init_weights":
|
||||
return CommandSpec(conda_python(conda_env, MY_DIR / "0_Initial_Save_All_Model_locally.py"), MMSEG_DIR, "download/save MMSeg pretrained weights locally")
|
||||
|
||||
if job_type == "mmseg.generate_data":
|
||||
return CommandSpec(conda_python(conda_env, MY_DIR / "1_Initial_Data_All_data_from_1_Data_Parameter-V2.py"), MMSEG_DIR, "generate MMSeg dataset configs from JSON parameters")
|
||||
|
||||
if job_type == "mmseg.generate_alg":
|
||||
script = MY_DIR / "2_Initial_Alg_All_data_from_2_Alg_Program-V2.py"
|
||||
return CommandSpec(
|
||||
conda_python(conda_env, script),
|
||||
MMSEG_DIR,
|
||||
"generate MMSeg algorithm config and training command",
|
||||
stdin_text=_stdin_for_generate_alg(params),
|
||||
)
|
||||
|
||||
if job_type == "mmseg.train":
|
||||
config_path = required(params, "config")
|
||||
args = conda_python(conda_env, MMSEG_DIR / "tools" / "train.py", config_path)
|
||||
append_flag(args, "--work-dir", params.get("work_dir"))
|
||||
return CommandSpec(args, MMSEG_DIR, "train MMSeg model")
|
||||
|
||||
if job_type == "mmseg.metrics":
|
||||
args = conda_python(conda_env, MY_DIR / "4_2_predict_matrics_from_log_V2.py")
|
||||
append_flag(args, "--input_dir", params.get("input_dir", "../Hardisk"))
|
||||
append_flag(args, "--output_dir", params.get("output_dir", "../BestMode_Predict_Results_DataSet_Public"))
|
||||
stdin = f"{params.get('dataset_choice', 1)}\n{params.get('algorithm_choice', 0)}\n"
|
||||
return CommandSpec(args, MMSEG_DIR, "extract best MMSeg metrics from logs", stdin_text=stdin)
|
||||
|
||||
if job_type == "mmseg.flops_fps":
|
||||
args = conda_python(conda_env, MY_DIR / "4_1_predict_params_FLOPs_FPS_V2.py")
|
||||
append_flag(args, "--input_dir", params.get("input_dir", "../Hardisk"))
|
||||
append_flag(args, "--output_dir", params.get("output_dir", "../BestMode_Predict_Results_DataSet_Public"))
|
||||
append_flag(args, "--repeat-times", params.get("repeat_times", 3))
|
||||
stdin = f"{params.get('dataset_choice', 1)}\n{params.get('algorithm_choice', 0)}\n"
|
||||
if "shape_h" in params and "shape_w" in params:
|
||||
stdin += f"{params['shape_h']}\n{params['shape_w']}\n"
|
||||
return CommandSpec(args, MMSEG_DIR, "calculate MMSeg FLOPs/Params/FPS", stdin_text=stdin)
|
||||
|
||||
if job_type == "mmseg.draw":
|
||||
return CommandSpec(conda_python(conda_env, MY_DIR / "4_3_predict_draw_pictures_and_tabels.py"), MMSEG_DIR, "generate MMSeg prediction pictures and tables")
|
||||
|
||||
if job_type == "mmseg.extract_loss_miou":
|
||||
return CommandSpec(conda_python(conda_env, MY_DIR / "4_4_extract_loss_and_best_miou.py"), MMSEG_DIR, "extract MMSeg loss and best mIoU curves")
|
||||
|
||||
if job_type == "mmseg.delete_epoch":
|
||||
return CommandSpec(conda_python(conda_env, MY_DIR / "3_Find_And_Delete_Special_Epoch.py"), MMSEG_DIR, "find and delete selected epoch checkpoints")
|
||||
|
||||
return None
|
||||
|
||||
2
backend/app/modules/segmodel/__init__.py
Normal file
2
backend/app/modules/segmodel/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""SegModel task wrappers."""
|
||||
|
||||
41
backend/app/modules/segmodel/tasks.py
Normal file
41
backend/app/modules/segmodel/tasks.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...commands import CommandSpec, append_flag, bash, conda_python, required
|
||||
from ...config import settings
|
||||
|
||||
|
||||
SEGMODEL_DIR = settings.source_root / "Seg_All_In_One_SegModel"
|
||||
|
||||
|
||||
def build_segmodel_task(job_type: str, params: dict, conda_env: str) -> CommandSpec | None:
|
||||
env = {"SEG_CONDA_ENV": conda_env}
|
||||
|
||||
if job_type == "segmodel.train":
|
||||
args = conda_python(conda_env, SEGMODEL_DIR / "train.py")
|
||||
append_flag(args, "-a", required(params, "architecture"))
|
||||
return CommandSpec(args, SEGMODEL_DIR, "train one segmentation_models_pytorch architecture")
|
||||
|
||||
if job_type == "segmodel.batch_train":
|
||||
return CommandSpec(bash(SEGMODEL_DIR / "train.sh"), SEGMODEL_DIR, "run legacy SegModel batch training", env=env)
|
||||
|
||||
if job_type == "segmodel.predict":
|
||||
args = conda_python(conda_env, SEGMODEL_DIR / "1_predict.py")
|
||||
append_flag(args, "-a", required(params, "architecture"))
|
||||
choice = str(params.get("run_choice", 1))
|
||||
return CommandSpec(args, SEGMODEL_DIR, "predict with one SegModel run", stdin_text=f"{choice}\n")
|
||||
|
||||
if job_type == "segmodel.batch_predict":
|
||||
return CommandSpec(bash(SEGMODEL_DIR / "predict.sh"), SEGMODEL_DIR, "run legacy SegModel batch prediction", env=env)
|
||||
|
||||
if job_type == "segmodel.flops":
|
||||
script = SEGMODEL_DIR / params.get("script", "2_predict_params_and_FLOPs_V2.py")
|
||||
return CommandSpec(conda_python(conda_env, script), SEGMODEL_DIR, "calculate SegModel params/FLOPs/FPS")
|
||||
|
||||
if job_type == "segmodel.raw_mask_check":
|
||||
return CommandSpec(conda_python(conda_env, SEGMODEL_DIR / "1_predict_raw_masks_check.py"), SEGMODEL_DIR, "check SegModel raw mask completeness")
|
||||
|
||||
if job_type == "segmodel.metrics":
|
||||
return CommandSpec(conda_python(conda_env, SEGMODEL_DIR / "3_predict_matrics_from_log.py"), SEGMODEL_DIR, "parse SegModel training/prediction metrics")
|
||||
|
||||
return None
|
||||
|
||||
2
backend/app/modules/system/__init__.py
Normal file
2
backend/app/modules/system/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""System task wrappers."""
|
||||
|
||||
106
backend/app/modules/system/service.py
Normal file
106
backend/app/modules/system/service.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...config import settings
|
||||
|
||||
|
||||
def parse_nvidia_smi_csv(output: str) -> list[dict]:
|
||||
gpus: list[dict] = []
|
||||
for line in output.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = [part.strip() for part in line.split(",")]
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
index, name, total, used, free, util, temp = parts[:7]
|
||||
try:
|
||||
gpus.append(
|
||||
{
|
||||
"index": int(index),
|
||||
"name": name,
|
||||
"memory_total_mb": int(total),
|
||||
"memory_used_mb": int(used),
|
||||
"memory_free_mb": int(free),
|
||||
"utilization_gpu_percent": int(util),
|
||||
"temperature_c": int(temp),
|
||||
}
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
return gpus
|
||||
|
||||
|
||||
def get_gpus() -> dict:
|
||||
cmd = [
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu",
|
||||
"--format=csv,noheader,nounits",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
return {"available": True, "gpus": parse_nvidia_smi_csv(result.stdout)}
|
||||
except Exception as exc:
|
||||
return {"available": False, "gpus": [], "error": str(exc)}
|
||||
|
||||
|
||||
def get_conda_envs() -> dict:
|
||||
try:
|
||||
result = subprocess.run(["conda", "env", "list"], capture_output=True, text=True, check=True)
|
||||
except Exception as exc:
|
||||
return {"available": False, "envs": [], "error": str(exc)}
|
||||
envs = []
|
||||
for line in result.stdout.splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or raw.startswith("#"):
|
||||
continue
|
||||
marker = "*" in raw.split()
|
||||
parts = raw.replace("*", " ").split()
|
||||
if len(parts) >= 2:
|
||||
envs.append({"name": parts[0], "path": parts[-1], "active": marker})
|
||||
return {"available": True, "envs": envs, "task_default": settings.task_conda_env}
|
||||
|
||||
|
||||
def disk_usage() -> dict:
|
||||
usage = shutil.disk_usage(settings.source_root)
|
||||
return {
|
||||
"path": str(settings.source_root),
|
||||
"total": usage.total,
|
||||
"used": usage.used,
|
||||
"free": usage.free,
|
||||
}
|
||||
|
||||
|
||||
def scan_results() -> list[dict]:
|
||||
roots = [
|
||||
settings.source_root / "DataSet_Public_outputs",
|
||||
settings.source_root / "BestMode_Predict_Results_DataSet_Public",
|
||||
settings.source_root / "Hardisk",
|
||||
settings.source_root / "Seg_All_In_One_Analysis",
|
||||
]
|
||||
exts = {".csv", ".png", ".jpg", ".jpeg", ".svg", ".log", ".pth", ".pt"}
|
||||
results: list[dict] = []
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file() and path.suffix.lower() in exts:
|
||||
try:
|
||||
stat = path.stat()
|
||||
results.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"path": str(path.resolve()),
|
||||
"relative_path": str(path.resolve().relative_to(settings.source_root)),
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"kind": path.suffix.lower().lstrip("."),
|
||||
}
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
results.sort(key=lambda item: item["modified"], reverse=True)
|
||||
return results[:1000]
|
||||
|
||||
14
backend/app/modules/system/tasks.py
Normal file
14
backend/app/modules/system/tasks.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...commands import CommandSpec, bash
|
||||
from ...config import settings
|
||||
|
||||
|
||||
def build_system_task(job_type: str, params: dict, conda_env: str) -> CommandSpec | None:
|
||||
if job_type == "system.backup":
|
||||
return CommandSpec(bash(settings.source_root / "Back_Up.sh"), settings.source_root, "run legacy backup script")
|
||||
if job_type == "mock.echo":
|
||||
message = params.get("message", "Seg Data Server mock job")
|
||||
return CommandSpec(["python", "-c", f"print({message!r})"], settings.project_root, "test job runner")
|
||||
return None
|
||||
|
||||
2
backend/app/modules/weights/__init__.py
Normal file
2
backend/app/modules/weights/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Weight sync and verification."""
|
||||
|
||||
153
backend/app/modules/weights/service.py
Normal file
153
backend/app/modules/weights/service.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from ...config import settings
|
||||
|
||||
WEIGHT_EXTS = {".pt", ".pth", ".onnx", ".engine"}
|
||||
|
||||
|
||||
def sha256_file(path: Path, chunk_size: int = 1024 * 1024 * 8) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def iter_source_weights() -> Iterable[Path]:
|
||||
project_root = settings.project_root.resolve()
|
||||
for path in settings.source_root.rglob("*"):
|
||||
if not path.is_file() or path.suffix.lower() not in WEIGHT_EXTS:
|
||||
continue
|
||||
try:
|
||||
path.resolve().relative_to(project_root)
|
||||
continue
|
||||
except ValueError:
|
||||
yield path
|
||||
|
||||
|
||||
def classify_weight(path: Path) -> dict[str, str]:
|
||||
try:
|
||||
rel = str(path.resolve().relative_to(settings.source_root))
|
||||
except ValueError:
|
||||
rel = str(path)
|
||||
lower = rel.lower()
|
||||
if "yolo" in lower:
|
||||
family = "yolo"
|
||||
elif "mmseg" in lower or "my_local_model" in lower:
|
||||
family = "mmseg"
|
||||
elif "segmodel" in lower:
|
||||
family = "segmodel"
|
||||
else:
|
||||
family = "misc"
|
||||
if "best.pt" in lower or "best.pth" in lower:
|
||||
role = "trained_best"
|
||||
elif "last.pt" in lower or "last.pth" in lower:
|
||||
role = "trained_last"
|
||||
elif "pretrain" in lower or "my_local_model" in lower:
|
||||
role = "pretrained"
|
||||
else:
|
||||
role = "weight"
|
||||
return {"family": family, "role": role}
|
||||
|
||||
|
||||
def copy_weight(src: Path, dst: Path, mode: str) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mode == "hardlink":
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
os.link(src, dst)
|
||||
elif mode == "reflink":
|
||||
subprocess.run(["cp", "--reflink=auto", "--preserve=timestamps", str(src), str(dst)], check=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def sync_weights(mode: str = "copy", hash_files: bool = True, skip_existing: bool = True) -> dict:
|
||||
files_dir = settings.weights_root / "files"
|
||||
settings.weights_root.mkdir(parents=True, exist_ok=True)
|
||||
entries = []
|
||||
total_bytes = 0
|
||||
for src in sorted(iter_source_weights()):
|
||||
rel = src.resolve().relative_to(settings.source_root)
|
||||
dst = files_dir / rel
|
||||
stat = src.stat()
|
||||
total_bytes += stat.st_size
|
||||
copied = False
|
||||
if not (skip_existing and dst.exists() and dst.stat().st_size == stat.st_size):
|
||||
copy_weight(src, dst, mode)
|
||||
copied = True
|
||||
meta = classify_weight(src)
|
||||
entry = {
|
||||
"source_path": str(rel),
|
||||
"stored_path": str(dst.resolve().relative_to(settings.project_root)),
|
||||
"size": stat.st_size,
|
||||
"family": meta["family"],
|
||||
"role": meta["role"],
|
||||
"copied": copied,
|
||||
}
|
||||
if hash_files:
|
||||
entry["sha256"] = sha256_file(dst)
|
||||
entries.append(entry)
|
||||
|
||||
manifest = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source_root": str(settings.source_root),
|
||||
"mode": mode,
|
||||
"count": len(entries),
|
||||
"total_bytes": total_bytes,
|
||||
"files": entries,
|
||||
}
|
||||
manifest_path = settings.weights_root / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return manifest
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
manifest_path = settings.weights_root / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
return {
|
||||
"generated_at": None,
|
||||
"source_root": str(settings.source_root),
|
||||
"count": 0,
|
||||
"total_bytes": 0,
|
||||
"files": [],
|
||||
}
|
||||
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def verify_weights() -> dict:
|
||||
manifest = load_manifest()
|
||||
checked = []
|
||||
ok_count = 0
|
||||
for entry in manifest.get("files", []):
|
||||
path = settings.project_root / entry["stored_path"]
|
||||
exists = path.exists()
|
||||
size_ok = exists and path.stat().st_size == entry.get("size")
|
||||
hash_ok = None
|
||||
if exists and "sha256" in entry:
|
||||
hash_ok = sha256_file(path) == entry["sha256"]
|
||||
ok = bool(exists and size_ok and (hash_ok is not False))
|
||||
ok_count += int(ok)
|
||||
checked.append(
|
||||
{
|
||||
"stored_path": entry["stored_path"],
|
||||
"exists": exists,
|
||||
"size_ok": size_ok,
|
||||
"hash_ok": hash_ok,
|
||||
"ok": ok,
|
||||
}
|
||||
)
|
||||
return {"count": len(checked), "ok_count": ok_count, "items": checked}
|
||||
2
backend/app/modules/yolo/__init__.py
Normal file
2
backend/app/modules/yolo/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""YOLO task wrappers."""
|
||||
|
||||
69
backend/app/modules/yolo/tasks.py
Normal file
69
backend/app/modules/yolo/tasks.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...commands import CommandSpec, append_flag, bash, conda_python, required
|
||||
from ...config import settings
|
||||
|
||||
|
||||
YOLO_DIR = settings.source_root / "Seg_All_In_One_YoloModel"
|
||||
VIDEO_YOLO_DIR = settings.source_root / "Seg_Predict_YoloModel"
|
||||
|
||||
|
||||
def build_yolo_task(job_type: str, params: dict, conda_env: str) -> CommandSpec | None:
|
||||
env = {"SEG_CONDA_ENV": conda_env}
|
||||
|
||||
if job_type == "yolo.train":
|
||||
args = conda_python(conda_env, YOLO_DIR / "yolo_train.py")
|
||||
append_flag(args, "--model", required(params, "model"))
|
||||
return CommandSpec(args, YOLO_DIR, "train one Ultralytics YOLO segmentation model")
|
||||
|
||||
if job_type == "yolo.batch_train":
|
||||
return CommandSpec(bash(YOLO_DIR / "yolo_train.sh"), YOLO_DIR, "run legacy YOLO batch training", env=env)
|
||||
|
||||
if job_type == "yolo.predict":
|
||||
args = conda_python(conda_env, YOLO_DIR / "yolo_predict_V2.py")
|
||||
append_flag(args, "--model", required(params, "model"))
|
||||
append_flag(args, "--source", params.get("source"))
|
||||
append_flag(args, "--pt_name", params.get("pt_name", "best.pt"))
|
||||
append_flag(args, "--conf", params.get("conf", 0.2))
|
||||
choice = str(params.get("run_choice", 1))
|
||||
return CommandSpec(args, YOLO_DIR, "predict with one YOLO model", stdin_text=f"{choice}\n")
|
||||
|
||||
if job_type == "yolo.batch_predict":
|
||||
args = bash(YOLO_DIR / "yolo_predict.sh")
|
||||
append_flag(args, "--pt_name", params.get("pt_name", "best.pt"))
|
||||
append_flag(args, "--conf", params.get("conf", 0.2))
|
||||
append_flag(args, "--heatmap_method", params.get("heatmap_method"))
|
||||
return CommandSpec(args, YOLO_DIR, "run legacy YOLO batch prediction", env=env)
|
||||
|
||||
if job_type == "yolo.heatmap":
|
||||
args = conda_python(conda_env, YOLO_DIR / "yolo_predict_visualize_nn.py")
|
||||
append_flag(args, "--model", required(params, "model"))
|
||||
append_flag(args, "--target_layers", params.get("target_layers", "default"))
|
||||
append_flag(args, "--cam_method", params.get("cam_method", "All"))
|
||||
append_flag(args, "--pt_name", params.get("pt_name", "best.pt"))
|
||||
choice = str(params.get("run_choice", 1))
|
||||
return CommandSpec(args, YOLO_DIR, "generate YOLO heatmaps", stdin_text=f"{choice}\n")
|
||||
|
||||
if job_type == "yolo.compare":
|
||||
args = conda_python(conda_env, YOLO_DIR / "yolo_predict_V2_compare_all.py")
|
||||
append_flag(args, "--pt_name", params.get("pt_name", "all"))
|
||||
return CommandSpec(args, YOLO_DIR, "compare all YOLO prediction outputs")
|
||||
|
||||
if job_type == "yolo.raw_mask_check":
|
||||
args = conda_python(conda_env, YOLO_DIR / "yolo_predict_raw_masks_check.py")
|
||||
append_flag(args, "--pt_name", params.get("pt_name", "best.pt"))
|
||||
return CommandSpec(args, YOLO_DIR, "check YOLO raw mask completeness")
|
||||
|
||||
if job_type == "yolo.copy_best":
|
||||
args = bash(YOLO_DIR / "Tool_Yolo_Copy_Best_Model.sh")
|
||||
append_flag(args, "--pt_name", params.get("pt_name", "best.pt"))
|
||||
return CommandSpec(args, YOLO_DIR, "copy YOLO best weights into prediction area")
|
||||
|
||||
if job_type == "yolo.video_visible":
|
||||
return CommandSpec(conda_python(conda_env, VIDEO_YOLO_DIR / "yolo_Seg_Video-V1-Visible.py"), VIDEO_YOLO_DIR, "render visible YOLO video prediction")
|
||||
|
||||
if job_type == "yolo.video_unvisible":
|
||||
return CommandSpec(conda_python(conda_env, VIDEO_YOLO_DIR / "yolo_Seg_Video-V2-UnVisible.py"), VIDEO_YOLO_DIR, "render invisible/headless YOLO video prediction")
|
||||
|
||||
return None
|
||||
|
||||
35
backend/app/paths.py
Normal file
35
backend/app/paths.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PathSecurityError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def ensure_inside(path: Path, root: Path) -> Path:
|
||||
resolved = path.expanduser().resolve()
|
||||
root_resolved = root.expanduser().resolve()
|
||||
try:
|
||||
resolved.relative_to(root_resolved)
|
||||
except ValueError as exc:
|
||||
raise PathSecurityError(f"path escapes root: {resolved}") from exc
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_user_path(value: str | None, default: Path, root: Path) -> Path:
|
||||
if value:
|
||||
candidate = Path(value).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = root / candidate
|
||||
else:
|
||||
candidate = default
|
||||
return ensure_inside(candidate, root)
|
||||
|
||||
|
||||
def rel(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return str(path.resolve().relative_to(root.resolve()))
|
||||
except ValueError:
|
||||
return str(path.resolve())
|
||||
|
||||
45
backend/app/schemas.py
Normal file
45
backend/app/schemas.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
type: str = Field(..., examples=["segmodel.train", "yolo.predict"])
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
gpus: list[int] | None = None
|
||||
conda_env: str | None = None
|
||||
|
||||
|
||||
class JobRecord(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
status: JobStatus
|
||||
params: dict[str, Any]
|
||||
command: list[str]
|
||||
cwd: str
|
||||
description: str = ""
|
||||
pid: int | None = None
|
||||
exit_code: int | None = None
|
||||
created_at: str
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
log_path: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
kind: str
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WeightSyncRequest(BaseModel):
|
||||
mode: Literal["copy", "reflink", "hardlink"] = "copy"
|
||||
hash_files: bool = True
|
||||
skip_existing: bool = True
|
||||
|
||||
Reference in New Issue
Block a user