Files
ModelTrainEval/scripts/push_predictions.py
T
Shaun ZhouandClaude Opus 5 474b7b50a0 feat(detect): 推理侧改为切片,与固定 160 输入一致
训练数据已经是固定边长的片,推理还把整图交给 ultralytics 就会被 letterbox 缩放,
与模型见过的形态不符。三个调用方(可视化、批量推送预测、ML backend)统一改成
切片推理再拼回原图。

- engine/tiling.merge_tiles / tiled_predict:映射回原图、裁掉镜像填充区的框、跨片去重。
  推理框架留在调用方,几何部分可单测。
- engine/detection.nms:标准 NMS,另加按包含比例抑制。缺陷被切割线切开时,相邻片
  只看到一角,给出的小残框与完整框 IoU 很小(面积悬殊),光靠 IoU 压不掉。
- engine/tiled_inference:ultralytics 的接线,内部分批,避免片数多时 OOM。
- 三处调用点加 --tile-size / --tile-overlap(后端为 YOLO_TILE_SIZE / YOLO_TILE_OVERLAP),
  默认 160 / 32;设 0 退回整图推理,仅用于和旧模型对比。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:12:35 +08:00

333 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""用指定权重给 Label Studio 项目里的所有任务批量生成预测。
uv run python scripts/push_predictions.py --project 39 \\
--weights runs/detect/current.pt
适合重训完成后,一次性把全部样本的预标注刷新成新模型的结果。
与网页端的 **Retrieve Predictions** 相比:那条路要经过 ML backend,Label Studio 会把选中的
任务一股脑发过去;本脚本在本地按 `--batch` 分批推理,再用批量导入接口
(`POST /api/projects/<id>/import/predictions`)一次提交一批,显存和请求体都可控,
也不依赖后端服务在跑。
需要一个 API token(网页端 Account & Settings → Access Token):
export LABEL_STUDIO_API_KEY=xxxx
新版 Label Studio 默认只认 **JWT 个人访问令牌**(组织设置里 `legacy_api_tokens_enabled=false`)。
脚本会自动识别:令牌形如 JWT(两个点分三段)时先用 `/api/token/refresh/` 换取 access token
并以 `Bearer` 发送,否则按旧式 `Token` 发送。
图片路径从任务的 `/data/local-files/?d=...` 还原,所以 `--document-root` 必须和
Label Studio 的 `LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 一致。
**预测是追加的,不会覆盖。** 同一个任务可以有多版预测,Label Studio 在项目设置里按
model_version 选用哪一版。重复跑同一个版本会堆重复预测:`--replace` 先删掉同名版本
(换了权重时一般删不到东西,很快),`--replace-all` 则一次删光项目里的所有预测。
"""
from __future__ import annotations
import argparse
import os
import sys
import urllib.parse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ml_backend"))
from convert import to_prediction # noqa: E402
from engine.tiled_inference import load_image, predict_image # noqa: E402
from utils import setup_logging # noqa: E402
from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402
logger = None
class LabelStudio:
"""Label Studio REST API 的最小封装。"""
def __init__(self, url: str, token: str) -> None:
import requests
self.url = url.rstrip("/")
self.session = requests.Session()
self.token = token
self._authorize()
def _authorize(self) -> None:
"""设置 Authorization 头;JWT 令牌会换成 access token。
新版默认关闭了旧式 `Token xxx`(组织设置 legacy_api_tokens_enabled=false),
个人访问令牌是一个 JWT refresh token,要先换成 access token 再以 Bearer 发送。
"""
if self.token.count(".") != 2: # 不是 JWT,按旧式 Token 处理
self.session.headers["Authorization"] = f"Token {self.token}"
return
response = self.session.post(
f"{self.url}/api/token/refresh/", json={"refresh": self.token}, timeout=60
)
if response.status_code >= 400:
raise RuntimeError(
f"用个人访问令牌换取 access token 失败(HTTP {response.status_code}):"
f"{response.text[:200]}"
)
self.session.headers["Authorization"] = f"Bearer {response.json()['access']}"
def request(self, method: str, path: str, **kwargs):
"""带自动续期的请求。
access token 默认只有 5 分钟有效期(Label Studio 没有覆盖 SimpleJWT 的默认值),
而推完上万条预测远不止 5 分钟,所以碰到 401 就用 refresh token 再换一个再试一次。
"""
kwargs.setdefault("timeout", 120)
response = self.session.request(method, f"{self.url}{path}", **kwargs)
if response.status_code == 401:
self._authorize()
response = self.session.request(method, f"{self.url}{path}", **kwargs)
if response.status_code >= 400:
# 带上响应体:Label Studio 的 4xx/5xx 常把真正原因写在里面,
# raise_for_status 只给一行 URL,排查时毫无用处
raise RuntimeError(
f"{method} {path} 失败 HTTP {response.status_code}:{response.text[:500]}"
)
return response
def tasks(self, project: int, page_size: int = 500) -> list[dict]:
"""列出项目里的全部任务(只取 id 与 data,分页拉完)。"""
collected, page = [], 1
while True:
response = self.request(
"GET", "/api/tasks/",
params={"project": project, "page": page, "page_size": page_size,
"fields": "task_only"},
)
payload = response.json()
# 不同版本的分页字段不一样:data manager 用 tasks/total,DRF 默认用 results/count
batch = payload.get("tasks", payload.get("results", payload)) if isinstance(
payload, dict
) else payload
if not batch:
break
collected.extend(batch)
total = payload.get("total", payload.get("count")) if isinstance(payload, dict) else None
if total is not None and len(collected) >= int(total):
break
page += 1
return collected
def delete_predictions(self, project: int, model_version: str) -> int:
"""删掉该项目下指定版本的预测,返回删除条数。
`/api/predictions/` **不分页**:Label Studio 的 REST_FRAMEWORK 没设
DEFAULT_PAGINATION_CLASS,PAGE_SIZE 形同虚设,它一次返回全部预测并忽略 page 参数。
早先按页翻会翻不到头(实测翻到第 1798 页才因令牌过期报错),所以这里只请求一次。
"""
payload = self.request("GET", "/api/predictions/", params={"project": project}).json()
items = payload.get("results", []) if isinstance(payload, dict) else payload
removed = 0
for item in items:
if item.get("model_version") == model_version:
self.request("DELETE", f"/api/predictions/{item['id']}/")
removed += 1
return removed
def delete_all_predictions(self, project: int) -> dict:
"""用 Data Manager 的批量操作一次删光项目里的所有预测(不分版本)。"""
return self.request(
"POST", "/api/dm/actions/",
params={"id": "delete_tasks_predictions", "project": project},
json={"selectedItems": {"all": True, "excluded": []}},
).json()
def import_predictions(self, project: int, predictions: list[dict]) -> None:
"""批量导入,服务端自己再分批入库。"""
self.request(
"POST", f"/api/projects/{project}/import/predictions",
json=predictions, timeout=600,
)
def local_path(image_url: str, document_root: Path) -> Path:
"""把任务里的图片 URL 还原成磁盘路径。
只处理本地存储(`/data/local-files/?d=相对路径`)——上传或云存储的图得走下载,
那种情况用网页端的 Retrieve Predictions 更省事。
"""
if "?d=" not in image_url:
raise ValueError(
f"不是本地存储的图片 URL:{image_url};本脚本只支持 /data/local-files/?d=..."
)
relative = urllib.parse.unquote(image_url.split("?d=", 1)[1])
return document_root / relative
def main() -> int:
global logger
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--project", type=int, required=True, help="Label Studio 项目 id")
parser.add_argument(
"--weights", default="runs/detect/current.pt",
help="权重;缺省用重训脚本维护的 current.pt 软链接",
)
parser.add_argument("--url", default=os.environ.get("LABEL_STUDIO_URL", "http://localhost:8080"))
parser.add_argument(
"--token", default=os.environ.get("LABEL_STUDIO_API_KEY", ""),
help="API token,缺省读环境变量 LABEL_STUDIO_API_KEY",
)
parser.add_argument(
"--document-root",
default=os.environ.get("LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT", "/home/deep/labeling"),
help="必须与 Label Studio 的同名环境变量一致",
)
parser.add_argument("--model-version", default=None, help="缺省按权重文件名与修改时间生成")
parser.add_argument("--conf", type=float, default=0.25)
parser.add_argument(
"--tile-size", type=int, default=160,
help="模型输入边长;按它切片推理再拼回,全程不缩放。0=整图推理(会 letterbox)",
)
parser.add_argument("--tile-overlap", type=int, default=32, help="切片重叠像素")
parser.add_argument("--imgsz", type=int, default=640, help="仅 --tile-size 0 时用")
parser.add_argument("--device", default="auto")
parser.add_argument("--batch", type=int, default=16, help="每次推理几张")
parser.add_argument("--chunk", type=int, default=500, help="每次上传几条预测")
parser.add_argument("--limit", type=int, default=0, help="只处理前 N 个任务(试运行用)")
parser.add_argument(
"--replace", action="store_true",
help="先删掉同一 model_version 的旧预测;否则会追加,同版本堆两份",
)
parser.add_argument(
"--replace-all", action="store_true",
help="先删光项目里的**所有**预测(不分版本),用 Data Manager 的批量操作,一次请求",
)
parser.add_argument("--dry-run", action="store_true", help="只推理不上传")
args = parser.parse_args()
logger = setup_logging()
if not args.token:
logger.error("缺少 API token:export LABEL_STUDIO_API_KEY=... 或用 --token 传入")
return 1
weights = Path(args.weights)
if not weights.exists():
logger.error("找不到权重 %s", weights)
return 1
model_version = args.model_version or f"{weights.resolve().stem}-{int(weights.stat().st_mtime)}"
try:
device = resolve_device(args.device)
except DeviceNotAvailableError as exc:
logger.error("%s", exc)
return 1
try:
client = LabelStudio(args.url, args.token)
except RuntimeError as exc:
logger.error("%s", exc)
return 1
logger.info("拉取项目 %d 的任务列表…", args.project)
tasks = client.tasks(args.project)
if args.limit:
tasks = tasks[: args.limit]
logger.info(
"任务 %d 个 | 权重 %s | 版本 %s | 设备 %s",
len(tasks), weights, model_version, describe_device(device),
)
if not tasks:
return 0
document_root = Path(args.document_root)
try:
pairs = [(task["id"], local_path(task["data"]["image"], document_root)) for task in tasks]
except (KeyError, ValueError) as exc:
logger.error("%s", exc)
return 1
missing = [path for _, path in pairs if not path.exists()]
if missing:
logger.error(
"有 %d 张图在磁盘上找不到,例如 %s;--document-root 是否与 Label Studio 一致(当前 %s)",
len(missing), missing[0], document_root,
)
return 1
if args.replace_all and not args.dry_run:
detail = client.delete_all_predictions(args.project)
logger.info("删除项目里的全部旧预测:%s", detail.get("detail", detail))
elif args.replace and not args.dry_run:
removed = client.delete_predictions(args.project, model_version)
logger.info("删除同版本(%s)旧预测 %d 条", model_version, removed)
from ultralytics import YOLO
model = YOLO(str(weights))
classes = model.names
device_arg = device.index or 0 if device.type == "cuda" else device.type
if args.tile_size > 0:
logger.info("切片推理 %dx%d,重叠 %d(不缩放)", args.tile_size, args.tile_size, args.tile_overlap)
else:
logger.warning("整图推理 imgsz=%d —— ultralytics 会 letterbox 缩放", args.imgsz)
pending: list[dict] = []
uploaded = with_boxes = total_boxes = 0
def flush() -> None:
nonlocal pending, uploaded
if not pending or args.dry_run:
pending = []
return
client.import_predictions(args.project, pending)
uploaded += len(pending)
pending = []
for processed, (task_id, path) in enumerate(pairs, start=1):
image = load_image(path)
height, width = image.shape[:2]
detections = predict_image(
model, image, size=args.tile_size, overlap=args.tile_overlap, conf=args.conf,
device=device_arg, batch=args.batch, whole_image_imgsz=args.imgsz,
)
boxes = [
(classes[box.class_id], (box.x1, box.y1, box.x2, box.y2), box.score)
for box in detections
]
prediction = to_prediction(boxes, width, height, "label", "image", model_version)
prediction["task"] = task_id
pending.append(prediction)
total_boxes += len(boxes)
with_boxes += bool(boxes)
if len(pending) >= args.chunk:
try:
flush()
except RuntimeError as exc:
logger.error("%s", exc)
return 1
if processed % 1000 == 0:
logger.info("处理 [%d/%d],已上传 %d", processed, len(pairs), uploaded)
try:
flush()
except RuntimeError as exc:
logger.error("%s", exc)
return 1
logger.info(
"完成:%d 个任务,%d 个有框(%d 个框),上传 %d 条预测,版本 %s",
len(pairs), with_boxes, total_boxes, uploaded, model_version,
)
if args.dry_run:
logger.info("--dry-run:未上传")
else:
logger.info(
"在项目设置 Annotation → Prelabeling 里把预测版本选成 %s 即可用于预标注", model_version
)
return 0
if __name__ == "__main__":
raise SystemExit(main())