feat: 合并 Docker Compose、标注表格优化与部署文档

将 platform + CVAT 合并为单文件 docker-compose.yml,完善 .env 与 init/dev_up 脚本;
新增 docs/DEPLOY.md 与更新 README 以支持新机器部署;含数据湖示例、车队地图、
紧凑表格 UI、ADAS det_7cls 路径与批次台账等近期改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 17:06:31 +08:00
parent 0b8ade048e
commit 483e027482
117 changed files with 5933 additions and 1499 deletions

View File

@@ -58,11 +58,107 @@ def _parse_labels(label_path: Path) -> list[dict[str, Any]]:
results = []
for line in label_path.read_text().strip().splitlines():
ann = _parse_yolo_line(line)
if ann:
if ann and ann["bbox"][2] > 0 and ann["bbox"][3] > 0:
results.append(ann)
return results
def _class_names_for_campaign(camp) -> dict[int, str]:
"""campaign task → class_id → name。"""
import yaml
from as_platform.data.core import load_wf, proj_root
if not camp or camp.project != "dms":
return {}
wf = load_wf()
root = proj_root(wf, "dms")
reg = yaml.safe_load((root / wf["projects"]["dms"]["registry"]).read_text(encoding="utf-8")) or {}
tcfg = (reg.get("tasks") or {}).get(camp.task) or {}
if camp.mode and tcfg.get("type") == "multi":
mcfg = (tcfg.get("modes") or {}).get(camp.mode) or {}
names = mcfg.get("names")
else:
names = tcfg.get("names")
if isinstance(names, list):
return {i: str(n) for i, n in enumerate(names)}
if isinstance(names, dict):
return {int(k): str(v) for k, v in names.items()}
return {}
def _name_to_class_id(name: str, class_names: dict[int, str]) -> int:
rev = {v.lower(): k for k, v in class_names.items()}
return rev.get(name.lower(), 0)
def _resolve_yolo_label_path(batch_dir: Path, img_path: Path) -> Path | None:
stem = img_path.stem
for rel in (
f"labels/{stem}.txt",
f"labels/train/{stem}.txt",
f"labels/val/{stem}.txt",
f"labels/yolo/{stem}.txt",
):
p = batch_dir / rel
if p.is_file():
return p
return None
def _parse_ls_annotations(path: Path, class_names: dict[int, str]) -> list[dict[str, Any]]:
import json
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
out: list[dict[str, Any]] = []
for item in data.get("result") or []:
if item.get("type") not in ("rectanglelabels", "rectangle"):
continue
val = item.get("value") or {}
w_pct = float(val.get("width") or 0)
h_pct = float(val.get("height") or 0)
if w_pct <= 0 or h_pct <= 0:
continue
x_pct = float(val.get("x") or 0)
y_pct = float(val.get("y") or 0)
labels = val.get("rectanglelabels") or val.get("labels") or []
label = labels[0] if labels else "unknown"
cid = _name_to_class_id(str(label), class_names)
cx = (x_pct + w_pct / 2) / 100.0
cy = (y_pct + h_pct / 2) / 100.0
out.append({"class_id": cid, "bbox": (cx, cy, w_pct / 100.0, h_pct / 100.0)})
return out
def _load_image_annotations(
batch_dir: Path,
img_path: Path,
class_names: dict[int, str],
) -> list[dict[str, Any]]:
yolo = _resolve_yolo_label_path(batch_dir, img_path)
if yolo:
anns = _parse_labels(yolo)
if anns:
return anns
from as_platform.labeling.annotate import _task_id_for_image
ann_json = batch_dir / "labels" / "ls_annotations" / f"{_task_id_for_image(img_path, batch_dir)}.json"
if ann_json.is_file():
return _parse_ls_annotations(ann_json, class_names)
return []
def _image_has_labels(batch_dir: Path, img_path: Path, class_names: dict[int, str]) -> bool:
return bool(_load_image_annotations(batch_dir, img_path, class_names))
def _list_review_images(batch_dir: Path) -> list[Path]:
from as_platform.labeling.annotate import _iter_batch_images
return list(_iter_batch_images(batch_dir))
# ── Optimized overlay render ──
PALETTE = [(220, 20, 60), (30, 144, 255), (50, 205, 50), (255, 165, 0), (186, 85, 211), (0, 206, 209)]
@@ -70,7 +166,7 @@ PALETTE = [(220, 20, 60), (30, 144, 255), (50, 205, 50), (255, 165, 0), (186, 85
def render_review_overlay(
image_path: Path,
label_path: Path | None,
batch_dir: Path,
class_names: dict[int, str],
*,
max_size: int = 800,
@@ -88,7 +184,7 @@ def render_review_overlay(
font = _get_font(max(12, min(16, w // 50)))
line_w = max(1, w // 400)
anns = _parse_labels(label_path) if label_path else []
anns = _load_image_annotations(batch_dir, image_path, class_names)
for ann in anns:
cid = ann["class_id"]
color = PALETTE[cid % len(PALETTE)]
@@ -140,17 +236,14 @@ def get_review_queue(campaign_id: str, offset: int = 0, limit: int = 20) -> dict
if not camp:
return {"items": [], "total": 0, "hint": "Campaign 不存在"}
batch_dir = resolve_campaign_batch_dir(camp)
class_names = _class_names_for_campaign(camp)
if not batch_dir or not batch_dir.is_dir():
return {"items": [], "total": 0, "hint": "批次目录不存在"}
img_dir = batch_dir / "images"
if not img_dir.is_dir():
all_images = _list_review_images(batch_dir)
if not all_images:
return {"items": [], "total": 0, "hint": "无 images 目录"}
all_images: list[Path] = []
for ext in IMAGE_EXTS:
all_images.extend(sorted(img_dir.rglob(f"*{ext}")))
# Get existing reviews
with session_scope() as db:
reviewed = {
@@ -160,26 +253,26 @@ def get_review_queue(campaign_id: str, offset: int = 0, limit: int = 20) -> dict
total = len(all_images)
page = all_images[offset:offset + limit]
score_counts = {"good": 0, "fine": 0, "bad": 0, "pending": 0}
items = []
for img in page:
rel = str(img.relative_to(batch_dir))
score = reviewed.get(rel, "pending")
score_counts[score] += 1
label_path = batch_dir / "labels" / (img.stem + ".txt")
items.append({
"id": rel, "image_path": rel,
"fileName": img.name,
"score": score,
"has_label": label_path.is_file(),
"has_label": _image_has_labels(batch_dir, img, class_names),
})
# Fill remaining counts
for img in all_images:
rel = str(img.relative_to(batch_dir))
s = reviewed.get(rel, "pending")
if s not in score_counts:
score_counts[s] = 0
with session_scope() as db:
db_counts = _review_db_counts(db, campaign_id)
reviewed_n = sum(db_counts.values())
score_counts = {
"good": db_counts.get("good", 0),
"fine": db_counts.get("fine", 0),
"bad": db_counts.get("bad", 0),
"pending": max(0, total - reviewed_n),
}
return {
"items": items, "total": total,
@@ -188,7 +281,7 @@ def get_review_queue(campaign_id: str, offset: int = 0, limit: int = 20) -> dict
}
def get_review_image(campaign_id: str, image_rel_path: str, class_names: dict[int, str]) -> bytes:
def get_review_image(campaign_id: str, image_rel_path: str) -> bytes:
from as_platform.labeling.annotate import resolve_campaign_batch_dir
from as_platform.db.engine import session_scope
from as_platform.db.models import LabelingCampaign
@@ -197,11 +290,13 @@ def get_review_image(campaign_id: str, image_rel_path: str, class_names: dict[in
if not camp:
raise FileNotFoundError("Campaign 不存在")
batch_dir = resolve_campaign_batch_dir(camp)
class_names = _class_names_for_campaign(camp)
if not batch_dir:
raise FileNotFoundError("批次不存在")
img_path = batch_dir / image_rel_path
lbl_path = batch_dir / "labels" / (img_path.stem + ".txt")
return render_review_overlay(img_path, lbl_path if lbl_path.is_file() else None, class_names)
if not img_path.is_file():
raise FileNotFoundError(f"图片不存在: {image_rel_path}")
return render_review_overlay(img_path, batch_dir, class_names)
def submit_review_scores(
@@ -251,22 +346,76 @@ def submit_review_scores(
reviewed = sum(counts.values())
if reviewed >= total_images and total_images > 0:
pass_rate = counts.get("good", 0) / max(total_images, 1)
new_stage = "review_approved" if pass_rate >= 0.8 else "review_rejected"
_update_campaign_stage(db, campaign_id, new_stage)
new_stage = _effective_stage_from_review(
counts.get("good", 0), counts.get("fine", 0), counts.get("bad", 0), total_images,
)
if new_stage and new_stage != "in_review":
raw = "review_approved" if new_stage == "labeling_submitted" else new_stage
_update_campaign_stage(db, campaign_id, raw)
return {"ok": True, "updated": updated, "auto_advanced": reviewed >= total_images if total_images > 0 else False}
auto_advanced = reviewed >= total_images if total_images > 0 else False
acceptable = counts.get("good", 0) + counts.get("fine", 0) if total_images > 0 else 0
final_stage = None
if auto_advanced and total_images > 0:
eff = _effective_stage_from_review(
counts.get("good", 0), counts.get("fine", 0), counts.get("bad", 0), total_images,
)
final_stage = "review_approved" if eff == "labeling_submitted" else eff
return {
"ok": True,
"updated": updated,
"auto_advanced": auto_advanced,
"stage": final_stage,
}
def _review_db_counts(db, campaign_id: str) -> dict[str, int]:
from sqlalchemy import func
from collections import Counter
rows = db.query(LabelingReview.score, func.count()).filter(
LabelingReview.campaign_id == campaign_id
).group_by(LabelingReview.score).all()
return {score: cnt for score, cnt in rows}
PASS_RATE_THRESHOLD = 0.8
def _effective_stage_from_review(good: int, fine: int, bad: int, total: int) -> str | None:
"""Return campaign status after QA is complete; None if images remain unreviewed."""
if total <= 0:
return None
reviewed = good + fine + bad
if reviewed < total:
return "in_review"
acceptable = good + fine
approved = acceptable / total >= PASS_RATE_THRESHOLD
return "labeling_submitted" if approved else "review_rejected"
def reconcile_review_stage(campaign_id: str) -> str | None:
"""Align stored campaign stage with current review scores (fixes stale rejections)."""
summary = _review_summary(campaign_id)
if not summary.get("complete"):
return summary.get("stage")
expected = _effective_stage_from_review(
summary["good"], summary["fine"], summary["bad"], summary["total"],
)
if not expected:
return summary.get("stage")
with session_scope() as db:
from as_platform.db.models import LabelingCampaign
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
return None
if camp.status == expected:
return expected
camp.status = expected
from as_platform.labeling.batch_stage import update_campaign_batch_meta_stage
update_campaign_batch_meta_stage(camp, expected)
db.commit()
return expected
def _update_campaign_stage(db, campaign_id: str, new_stage: str) -> None:
from as_platform.db.models import LabelingCampaign
from as_platform.labeling.batch_stage import update_campaign_batch_meta_stage
@@ -278,10 +427,63 @@ def _update_campaign_stage(db, campaign_id: str, new_stage: str) -> None:
update_campaign_batch_meta_stage(camp, effective)
def review_progress(campaign_id: str) -> dict[str, int]:
def _review_summary(campaign_id: str) -> dict[str, Any]:
from as_platform.labeling.annotate import resolve_campaign_batch_dir
from as_platform.db.models import LabelingCampaign
with session_scope() as db:
rows = db.query(LabelingReview).filter(LabelingReview.campaign_id == campaign_id).all()
counts = {"good": 0, "fine": 0, "bad": 0, "pending": 0}
for r in rows:
counts[r.score] = counts.get(r.score, 0) + 1
return counts
camp = db.get(LabelingCampaign, campaign_id)
if not camp:
return {"good": 0, "fine": 0, "bad": 0, "pending": 0, "total": 0, "reviewed": 0, "pass_rate": 0, "complete": False, "stage": ""}
batch_dir = resolve_campaign_batch_dir(camp)
stage = camp.status or ""
if not batch_dir or not batch_dir.is_dir():
counts = _review_db_counts(db, campaign_id)
reviewed = sum(counts.values())
return {
**{k: counts.get(k, 0) for k in ("good", "fine", "bad")},
"pending": 0,
"total": reviewed,
"reviewed": reviewed,
"pass_rate": round((counts.get("good", 0) + counts.get("fine", 0)) / max(reviewed, 1) * 100),
"complete": reviewed > 0,
"stage": stage,
}
all_images = _list_review_images(batch_dir)
db_counts = _review_db_counts(db, campaign_id)
total = len(all_images)
good = db_counts.get("good", 0)
fine = db_counts.get("fine", 0)
bad = db_counts.get("bad", 0)
reviewed = good + fine + bad
acceptable = good + fine
return {
"good": good,
"fine": fine,
"bad": bad,
"pending": max(0, total - reviewed),
"total": total,
"reviewed": reviewed,
"pass_rate": round(acceptable / max(total, 1) * 100),
"complete": reviewed >= total and total > 0,
"stage": stage,
}
def review_progress(campaign_id: str) -> dict[str, Any]:
result = _review_summary(campaign_id)
if result.get("complete"):
reconciled = reconcile_review_stage(campaign_id)
if reconciled:
result["stage"] = reconciled
return result
def review_progress_batch(campaign_ids: list[str]) -> dict[str, Any]:
ids = [c.strip() for c in campaign_ids if c and c.strip()][:50]
items: dict[str, Any] = {}
for cid in ids:
items[cid] = review_progress(cid)
return {"items": items}