209 lines
7.5 KiB
Python
209 lines
7.5 KiB
Python
"""服务:将自动标注(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
|