from __future__ import annotations import json import math import time from datetime import datetime, timezone from pathlib import Path from PIL import Image, ImageDraw from .. import db from ..capabilities import get_capability_matrix from ..config import settings from ..jobs import create_job from ..modules.dataset.service import create_dataset, dataset_dir, generate_yolo_dataset_yaml, validate_dataset from ..modules.results.service import scan_results, scan_training_curves from ..schemas import JobCreate REPORT_PATH = settings.project_root / "var" / "agent_reports" / "user_agent_latest.json" def _now_id() -> str: return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f") def _polygon_line(class_id: int, points: list[tuple[float, float]], width: int, height: int) -> str: normalized = [] for x, y in points: normalized.extend([max(0, min(1, x / width)), max(0, min(1, y / height))]) return f"{class_id} " + " ".join(f"{value:.6f}" for value in normalized) def _ellipse_points(cx: float, cy: float, rx: float, ry: float, count: int = 24) -> list[tuple[float, float]]: return [ (cx + math.cos(index / count * math.tau) * rx, cy + math.sin(index / count * math.tau) * ry) for index in range(count) ] def _write_open_synthetic_dataset(dataset_name: str, count: int = 6) -> dict: create_dataset(dataset_name, "CC0-style synthetic segmentation data generated by the user agent.") root = dataset_dir(dataset_name) width = 160 height = 128 samples = [] for index in range(count): stem = f"open_shape_{index:02d}" image = Image.new("RGB", (width, height), (20 + index * 8, 28, 34)) mask = Image.new("L", (width, height), 0) overlay = Image.new("RGB", (width, height), (0, 0, 0)) draw = ImageDraw.Draw(image) mask_draw = ImageDraw.Draw(mask) overlay_draw = ImageDraw.Draw(overlay) ellipse = _ellipse_points(54 + index * 7, 54, 24, 18) rectangle = [(92, 70), (132, 70), (132, 104), (92, 104)] draw.polygon(ellipse, fill=(108, 193, 112)) draw.polygon(rectangle, fill=(104, 168, 230)) mask_draw.polygon(ellipse, fill=1) mask_draw.polygon(rectangle, fill=2) overlay_draw.polygon(ellipse, fill=(108, 193, 112)) overlay_draw.polygon(rectangle, fill=(104, 168, 230)) image_path = root / "images" / f"{stem}.png" mask_path = root / "masks" / f"{stem}.png" label_path = root / "labels" / f"{stem}.txt" image.save(image_path) mask.save(mask_path) label_path.write_text( "\n".join( [ _polygon_line(0, ellipse, width, height), _polygon_line(1, rectangle, width, height), ] ) + "\n", encoding="utf-8", ) samples.append({"image": str(image_path), "mask": str(mask_path), "label": str(label_path)}) manifest = { "dataset": dataset_name, "license": "CC0 synthetic data generated locally by Seg Data Server Net user agent", "classes": ["soft_organ", "instrument"], "samples": samples, } (root / "open_synthetic_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") return manifest def _write_review_artifacts(dataset_name: str) -> dict: root = dataset_dir(dataset_name) output_root = settings.project_root / "var" / "custom_yolo_runs" / f"{dataset_name}_user_agent_review" predict_dir = output_root / "predict" heatmap_dir = output_root / "heatmap" predict_dir.mkdir(parents=True, exist_ok=True) heatmap_dir.mkdir(parents=True, exist_ok=True) for image_path in sorted((root / "images").glob("*.png"))[:3]: image = Image.open(image_path).convert("RGB") mask = Image.open(root / "masks" / image_path.name).convert("L") overlay = Image.blend(image, Image.merge("RGB", (mask.point(lambda p: p * 90), mask.point(lambda p: p * 50), mask.point(lambda p: p * 20))), 0.35) overlay.save(predict_dir / f"{image_path.stem}_segmentation.png") heat = Image.new("RGB", image.size, (0, 0, 40)) heat_draw = ImageDraw.Draw(heat) heat_draw.ellipse((32, 24, 112, 96), fill=(255, 70, 30)) heat_draw.rectangle((82, 60, 150, 118), fill=(45, 220, 255)) Image.blend(image, heat, 0.5).save(heatmap_dir / f"{image_path.stem}_heatmap.png") results_csv = output_root / "results.csv" results_csv.write_text( "\n".join( [ "epoch,train/box_loss,train/seg_loss,metrics/mIoU", "0,1.000,0.850,0.420", "1,0.720,0.610,0.630", "2,0.530,0.430,0.760", ] ) + "\n", encoding="utf-8", ) return { "root": str(output_root), "predict_dir": str(predict_dir), "heatmap_dir": str(heatmap_dir), "results_csv": str(results_csv), } def _wait_job(job_id: str, timeout_seconds: float = 10) -> dict | None: deadline = time.time() + timeout_seconds while time.time() < deadline: job = db.get_job(job_id) if job and job["status"] in {"success", "failed", "cancelled"}: return job time.sleep(0.2) return db.get_job(job_id) def _save_report(report: dict) -> dict: REPORT_PATH.parent.mkdir(parents=True, exist_ok=True) REPORT_PATH.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report def latest_user_agent_report() -> dict: if not REPORT_PATH.exists(): return {"available": False, "agent": "user_agent", "passed": False} return json.loads(REPORT_PATH.read_text(encoding="utf-8")) def run_user_agent() -> dict: """Act like a first-time operator with a small open synthetic segmentation dataset.""" db.init_db() run_id = _now_id() dataset_name = f"user_agent_open_shapes_{run_id}" data_manifest = _write_open_synthetic_dataset(dataset_name) validation = validate_dataset(dataset_name) yolo_yaml = generate_yolo_dataset_yaml(dataset_name, ["soft_organ", "instrument"]) artifacts = _write_review_artifacts(dataset_name) mock_job = create_job(JobCreate(type="mock.echo", params={"message": f"user-agent checked {dataset_name}"})) finished_job = _wait_job(mock_job["id"]) results = scan_results(limit=1000) curves = scan_training_curves(limit=100) capabilities = get_capability_matrix() result_prefix = f"var/custom_yolo_runs/{dataset_name}_user_agent_review" visible_artifacts = [item for item in results if item["relative_path"].startswith(result_prefix)] visible_curves = [item for item in curves if item["relative_path"].startswith(result_prefix)] checks = [ {"name": "synthetic_open_dataset_created", "passed": len(data_manifest["samples"]) >= 6}, {"name": "image_mask_pairs_ready", "passed": validation["ready"]["mask"] and validation["pairs"]["image_mask"] >= 6, "detail": validation["pairs"]}, {"name": "yolo_labels_ready", "passed": validation["ready"]["yolo"] and validation["counts"]["annotations"] >= 12, "detail": validation["counts"]}, {"name": "dataset_yaml_generated", "passed": Path(yolo_yaml["path"]).exists(), "detail": yolo_yaml["relative_path"]}, {"name": "job_runner_used", "passed": bool(finished_job and finished_job["status"] == "success"), "detail": finished_job}, {"name": "result_artifacts_visible", "passed": len(visible_artifacts) >= 4, "detail": [item["relative_path"] for item in visible_artifacts[:8]]}, {"name": "training_curve_visible", "passed": len(visible_curves) >= 1, "detail": [item["relative_path"] for item in visible_curves[:4]]}, {"name": "capability_matrix_still_ready", "passed": capabilities["passed"], "detail": capabilities["summary"]}, ] suggestions = [ "推理页已经能选择训练权重与数据集图片源;建议下一步加一个批量对比视图,把多个 best.pt 对同一图片的输出并排显示。", "数据集页能发现 image/label/mask 配对问题;建议后续提供彩色 label 调色板在线编辑与一键灰度 mask 转换。", "结果页能读取合成预测图、热度图和 loss CSV;建议为真实长训任务增加按 run_id 固定筛选的结果集合。", ] report = { "available": True, "agent": "user_agent", "passed": all(item["passed"] for item in checks), "run_id": run_id, "created_at": datetime.now(timezone.utc).isoformat(), "dataset": { "name": dataset_name, "root": validation["root"], "license": data_manifest["license"], "counts": validation["counts"], "pairs": validation["pairs"], "yaml": yolo_yaml["relative_path"], }, "artifacts": artifacts, "checks": checks, "suggestions": suggestions, } return _save_report(report)