fix(scripts): push_predictions 的分页与令牌过期
两个在真实环境暴露的 bug: 1. /api/predictions/ 根本不分页——Label Studio 的 REST_FRAMEWORK 没设 DEFAULT_PAGINATION_CLASS,PAGE_SIZE 形同虚设,它一次返回全部预测并忽略 page。 原先按页翻永远不终止,实测翻到第 1798 页才因别的原因报错。改成只请求一次。 2. access token 默认只有 5 分钟(LS 未覆盖 SimpleJWT 默认值),而推上万条预测远超 5 分钟。所有请求统一走 request(),碰到 401 自动用 refresh token 续期后重试一次。 另加 --replace-all:用 Data Manager 的批量操作一次删光项目里所有预测, 比逐条删同版本快得多(适合版本号变了、旧预测留着没用的情况)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ead8c0a6e
commit
744e5fd18a
+59
-43
@@ -23,7 +23,8 @@
|
||||
Label Studio 的 `LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT` 一致。
|
||||
|
||||
**预测是追加的,不会覆盖。** 同一个任务可以有多版预测,Label Studio 在项目设置里按
|
||||
model_version 选用哪一版。重复跑同一个版本会堆重复预测,用 `--replace` 先删掉同名版本。
|
||||
model_version 选用哪一版。重复跑同一个版本会堆重复预测:`--replace` 先删掉同名版本
|
||||
(换了权重时一般删不到东西,很快),`--replace-all` 则一次删光项目里的所有预测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -53,37 +54,51 @@ class LabelStudio:
|
||||
|
||||
self.url = url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers["Authorization"] = self._authorization(token)
|
||||
self.token = token
|
||||
self._authorize()
|
||||
|
||||
def _authorization(self, token: str) -> str:
|
||||
"""区分两种令牌。
|
||||
def _authorize(self) -> None:
|
||||
"""设置 Authorization 头;JWT 令牌会换成 access token。
|
||||
|
||||
新版默认关闭了旧式 `Token xxx`(组织设置 legacy_api_tokens_enabled=false),
|
||||
个人访问令牌是一个 JWT refresh token,要先换成 access token 再以 Bearer 发送。
|
||||
"""
|
||||
if token.count(".") != 2: # 不是 JWT,按旧式 Token 处理
|
||||
return f"Token {token}"
|
||||
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": token}, timeout=60
|
||||
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]}"
|
||||
)
|
||||
return f"Bearer {response.json()['access']}"
|
||||
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)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def tasks(self, project: int, page_size: int = 500) -> list[dict]:
|
||||
"""列出项目里的全部任务(只取 id 与 data,分页拉完)。"""
|
||||
collected, page = [], 1
|
||||
while True:
|
||||
response = self.session.get(
|
||||
f"{self.url}/api/tasks/",
|
||||
response = self.request(
|
||||
"GET", "/api/tasks/",
|
||||
params={"project": project, "page": page, "page_size": page_size,
|
||||
"fields": "task_only"},
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
# 不同版本的分页字段不一样:data manager 用 tasks/total,DRF 默认用 results/count
|
||||
batch = payload.get("tasks", payload.get("results", payload)) if isinstance(
|
||||
@@ -99,41 +114,35 @@ class LabelStudio:
|
||||
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
|
||||
page = 1
|
||||
while True:
|
||||
response = self.session.get(
|
||||
f"{self.url}/api/predictions/",
|
||||
params={"project": project, "page": page, "page_size": 500},
|
||||
timeout=120,
|
||||
)
|
||||
if response.status_code == 404: # 翻到尾页,某些版本直接返回 404
|
||||
break
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("results", payload) if isinstance(payload, dict) else payload
|
||||
if not items:
|
||||
break
|
||||
for item in items:
|
||||
if item.get("model_version") == model_version:
|
||||
self.session.delete(
|
||||
f"{self.url}/api/predictions/{item['id']}/", timeout=60
|
||||
).raise_for_status()
|
||||
removed += 1
|
||||
if isinstance(payload, dict) and not payload.get("next"):
|
||||
break
|
||||
page += 1
|
||||
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:
|
||||
"""批量导入,服务端自己再分批入库。"""
|
||||
response = self.session.post(
|
||||
f"{self.url}/api/projects/{project}/import/predictions",
|
||||
json=predictions,
|
||||
timeout=600,
|
||||
self.request(
|
||||
"POST", f"/api/projects/{project}/import/predictions",
|
||||
json=predictions, timeout=600,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def local_path(image_url: str, document_root: Path) -> Path:
|
||||
@@ -181,6 +190,10 @@ def main() -> int:
|
||||
"--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()
|
||||
|
||||
@@ -231,9 +244,12 @@ def main() -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.replace and not args.dry_run:
|
||||
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("删除同版本旧预测 %d 条", removed)
|
||||
logger.info("删除同版本(%s)旧预测 %d 条", model_version, removed)
|
||||
|
||||
from PIL import Image
|
||||
from ultralytics import YOLO
|
||||
|
||||
Reference in New Issue
Block a user