"""服务:将自动标注(YOLO)结果同步到 Label Studio 作为 predictions。 该服务不负责执行模型推理,只负责: - 读取本地 annotations JSON(由 runtime/ops/annotation/image_object_detection_bounding_box 产出); - 根据 Label Studio 项目的任务列表,将每个文件对应到具体 task; - 将检测结果转换为 Label Studio 的 prediction 结构并写入。 """ from __future__ import annotations import json import os import uuid from typing import Dict, Any, List, Optional from app.core.logging import get_logger from ..client import LabelStudioClient logger = get_logger(__name__) class PredictionSyncService: """将 YOLO 检测结果推送到 Label Studio 的预测服务。""" def __init__(self, ls_client: LabelStudioClient) -> None: self.ls_client = ls_client async def _build_task_index(self, project_id: str) -> Dict[str, int]: """为指定项目构建 "文件名 -> task_id" 索引。 优先使用 data.original_name,其次退回到从 data.image / data.file_path 中解析出的文件名,保证与 YOLO JSON 中的 annotations["image"] 对得上。 """ result = await self.ls_client.get_project_tasks(project_id=project_id, page=None) if not result: logger.warning("No tasks found for project %s", project_id) return {} tasks = result.get("tasks", []) or [] index: Dict[str, int] = {} for task in tasks: task_id = task.get("id") data = task.get("data", {}) or {} if task_id is None: continue # candidate names that may appear in YOLO annotations["image"] candidates: List[str] = [] original_name = data.get("original_name") if isinstance(original_name, str) and original_name: candidates.append(original_name) image_val = data.get("image") or data.get("file_path") if isinstance(image_val, str) and image_val: basename = os.path.basename(image_val) if basename: candidates.append(basename) for name in candidates: # 后写覆盖前写没关系,同一文件名对应同一个 task 的预期 index[name] = task_id logger.debug( "Built task index for project %s with %d entries", project_id, len(index) ) return index @staticmethod def _build_ls_result_from_detections( annotations: Dict[str, Any], from_name: str = "label", to_name: str = "image", ) -> List[Dict[str, Any]]: """将 YOLO annotations["detections"] 转为 Label Studio result 列表。""" width = float(annotations.get("width") or 0) or 1.0 height = float(annotations.get("height") or 0) or 1.0 detections = annotations.get("detections") or [] results: List[Dict[str, Any]] = [] for det in detections: try: label = det.get("label") bbox = det.get("bbox_xywh") or det.get("bbox_xyxy") if not label or not bbox or len(bbox) < 4: continue # 优先使用 xywh,单位像素 if len(bbox) == 4: x, y, w, h = map(float, bbox) else: # 回退:如果给的是 xyxy x1, y1, x2, y2 = map(float, bbox[:4]) x, y, w, h = x1, y1, x2 - x1, y2 - y1 x_pct = max(0.0, min(100.0, x / width * 100.0)) y_pct = max(0.0, min(100.0, y / height * 100.0)) w_pct = max(0.0, min(100.0, w / width * 100.0)) h_pct = max(0.0, min(100.0, h / height * 100.0)) region_id = uuid.uuid4().hex[:10] results.append( { "id": region_id, "type": "rectanglelabels", "value": { "x": x_pct, "y": y_pct, "width": w_pct, "height": h_pct, "rotation": 0, "rectanglelabels": [label], }, "origin": "prediction", "to_name": to_name, "from_name": from_name, "image_rotation": 0, "original_width": width, "original_height": height, } ) except Exception as exc: # 防御性:单条失败不影响其他 logger.warning("Failed to convert detection to LS result: %s", exc) continue return results async def sync_predictions_from_dir( self, project_id: str, annotations_dir: str, *, model_version: Optional[str] = None, from_name: str = "label", to_name: str = "image", ) -> int: """将某个目录下的 YOLO JSON 同步为给定项目的 predictions。 返回成功写入 prediction 的任务数。 """ if not os.path.isdir(annotations_dir): logger.warning("annotations_dir '%s' is not a directory", annotations_dir) return 0 task_index = await self._build_task_index(project_id) if not task_index: logger.warning( "No tasks found when syncing predictions for project %s", project_id ) return 0 created_count = 0 for filename in os.listdir(annotations_dir): if not filename.lower().endswith(".json"): continue json_path = os.path.join(annotations_dir, filename) try: with open(json_path, "r", encoding="utf-8") as f: annotations = json.load(f) except Exception as exc: logger.warning("Failed to load annotations json %s: %s", json_path, exc) continue image_name = annotations.get("image") or filename.rsplit(".", 1)[0] task_id = task_index.get(image_name) if task_id is None: logger.info( "No matching task for image '%s' in project %s, skip", image_name, project_id ) continue result = self._build_ls_result_from_detections( annotations, from_name=from_name, to_name=to_name ) if not result: logger.info("No detections for %s, skip creating prediction", image_name) continue mv = model_version if mv is None: model_size = annotations.get("model_size") or "" mv = f"yolov8{model_size}" if model_size else "yolov8" prediction = await self.ls_client.create_prediction( task_id=task_id, result=result, model_version=mv, ) if prediction: created_count += 1 logger.info( "Synced YOLO predictions from '%s' to project %s, created %d predictions", annotations_dir, project_id, created_count, ) return created_count