训练数据已经是固定边长的片,推理还把整图交给 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>
333 lines
14 KiB
Python
333 lines
14 KiB
Python
#!/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())
|