spike(vision): rtmpose latency, birefnet matting proxies, body-shape prototype
T-P0-16/12/10 可行性验证:CPU 基准 + 代理质检指标 + 合成图体型剖面法 原型 + REPORT.md 三问三答(Sprint 1 门槛的决策依据)。 偏差说明:模型齐全未触发降级(rtmpose-s/m + birefnet_lite fp32 全部首选 候选成功,hf-mirror 实测 4~12MB/s);huggingface_hub 不在 spike extra 且 禁新增依赖,改用 stdlib urllib 直连 resolve 端点(同 HTTP 语义);PINTO model zoo 无法脚本化定位小模型、dhkim0115 仓不存在,姿态模型改用 bukuroo/RTMPose-ONNX;BiRefNet 官方仓无 ONNX,用 onnx-community 导出; GPU 档未测(CPU 版 ORT 无 CUDA EP,按文档不折腾);BiRefNet 单张>3s 正式 次数自适应降为 10 并在输出注明;RTMPose 在合成剪影上置信度低(域差异), 体型断言按文档允许走真值定高路径,关键点路径仅统计参考一致率。
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
# T9 · 视觉算法 SPIKE 报告(RTMPose 体型原型 + BiRefNet 抠图)
|
||||||
|
|
||||||
|
> 分支 `task/t9-vision-spike` | 2026-08-14 | 对应任务 T-P0-16 / T-P0-12 / TD-06 前置
|
||||||
|
> 测试机:Intel Core Ultra 7 265K(WSL 分配 16 vCPU、54GB 内存),onnxruntime 1.28.0(CPU 版)、opencv-python-headless 5.0.0、numpy 2.5.2
|
||||||
|
> 所有输入均为 OpenCV 合成人形图(`synthetic.py` 确定性生成),**未使用任何真实人脸/人体数据**(合规红线)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、三问三答(结论先行)
|
||||||
|
|
||||||
|
### Q1 RTMPose ONNX 在 CPU 上的单张推理耗时是多少?(T-P0-16)
|
||||||
|
|
||||||
|
**结论:通过。** RTMPose-s 单张纯推理 **2.2ms(P95 2.6ms / 最大 2.7ms)**,含预处理与解码的全链路 **2.8ms(P95 3.3ms / 最大 3.5ms)**;RTMPose-m 全链路 5.9ms。CPU 单张耗时对服务端批量/异步处理完全无压力,T-P0-16 验收口径(可接受即通过)满足。GPU 档未测:开发机有 RTX 5090(nvidia-smi 可见),但 venv 装的是 CPU 版 onnxruntime(无 CUDAExecutionProvider),按任务文档约定不装 GPU 版、只报 CPU 档。
|
||||||
|
|
||||||
|
### Q2 BiRefNet ONNX 抠图的质量与成本如何?(T-P0-12 初步)
|
||||||
|
|
||||||
|
**结论:有条件通过。** 管线成立:BiRefNet_lite fp32 在合成图上前景占比 24.4%(区间 15%~60% 内)、最大连通域占前景 1.0(单一主体)、边缘羽化带约 1px、纯色与渐变背景结果一致。**条件/风险有二**:① 单张全链路 **约 3.1s(CPU,1024×1024 输入)**,同步接口不可接受,必须走异步任务或降档(见 §四);② 本 SPIKE 的指标是**代理口径**(合成图 + 几何指标),T-P0-12 的「质检通过率 ≥90%」必须用真实素材 + 人工标注验证,合成图结果不能外推到正式验收。
|
||||||
|
|
||||||
|
### Q3 「关键点定高 + 掩膜宽度剖面」体型测算原型是否成立?(TD-06 前置)
|
||||||
|
|
||||||
|
**结论:通过(原型口径)。** 管线端到端跑通:定高 → 掩膜剖面取宽 → 肩/腰/臀比值 → 规则初判。**真值定高路径 6/6 变体(H/A/V/O/X 全类别 + 中性 STD)分类硬断言全部通过**;掩膜测宽与几何真值偏差仅 ±1px(一个变体腰线 +3px);关键点(RTMPose)定高路径参考一致率 6/6。两点保留:① COCO-17 无腰点,腰线取肩臀中点是代理,对真实人体(腰臀比例分布)是否成立待验证;② RTMPose 在合成剪影上置信度极低(见 §三),KP 路径在真实照片上的可用性未验证;≥80% 一致率的正式验证需要顾问轨人工标注(本任务不承诺)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、基准数字表
|
||||||
|
|
||||||
|
### 2.1 RTMPose CPU 单张耗时(预热 3 + 正式 20,`time.perf_counter`)
|
||||||
|
|
||||||
|
| 模型 | 链路 | 均值 | P95 | 单次最大 |
|
||||||
|
| --- | --- | ---: | ---: | ---: |
|
||||||
|
| rtmpose-s(20.9MB,输入 256×192) | 纯模型 `session.run` | 2.2ms | 2.6ms | 2.7ms |
|
||||||
|
| rtmpose-s | 全链路(resize+归一化+推理+SimCC 解码) | 2.8ms | 3.3ms | 3.5ms |
|
||||||
|
| rtmpose-m(51.8MB,输入 256×192) | 纯模型 | 5.2ms | 5.7ms | 5.8ms |
|
||||||
|
| rtmpose-m | 全链路 | 5.9ms | 6.2ms | 6.5ms |
|
||||||
|
|
||||||
|
- 预处理+解码开销约 0.6~0.7ms(全链路 − 纯模型),与模型规模无关。
|
||||||
|
- 耗时口径说明:RTMPose 是 top-down 模型,本基准以「合成图已知完美人体框、整图缩放」为输入,即纯姿态网络耗时;生产链路还需前置一个人体检测器(见 §四建议)。
|
||||||
|
|
||||||
|
### 2.2 BiRefNet_lite 抠图代理质检指标(预热 3 + 正式 10,单张 >3s 自适应降档并注明)
|
||||||
|
|
||||||
|
| 输入 | 前景占比 | 最大连通域/前景 | 连通域数 | 边缘羽化带 | 均值耗时 | P95 | 最大 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 纯色背景 | 0.2444 | 1.0000 | 1 | 0.97px | 3097ms | 3128ms | 3131ms |
|
||||||
|
| 渐变背景 | 0.2443 | 1.0000 | 1 | 1.02px | 3097ms | 3131ms | 3139ms |
|
||||||
|
|
||||||
|
- 前景占比处于 15%~60% 合理区间,主体单一完整(>0.9 阈值达成,实际 1.0)。
|
||||||
|
- 羽化带 ≈1px:对高对比合成图边缘干净利落,真实照片(毛发/半透明)会显著变宽,该值应作为正式质检的分档特征而非固定阈值。
|
||||||
|
- 两背景变体指标几乎一致 → 对背景明暗变化不敏感(该结论仅限合成域)。
|
||||||
|
|
||||||
|
### 2.3 体型测算原型(合成图,掩膜 = BiRefNet_lite)
|
||||||
|
|
||||||
|
| 变体 | 期望 | 真值定高(硬断言) | 关键点定高(参考) | 掩膜测宽 肩/腰/臀(px) | 保真度偏差(px) |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| STD(中性) | H | **H ✓** | H ✓ | 131/111/131 | +1/+1/+1 |
|
||||||
|
| H | H | **H ✓** | H ✓ | 127/121/125 | +1/+1/+1 |
|
||||||
|
| A | A | **A ✓** | A ✓ | 99/103/147 | +1/+1/+1 |
|
||||||
|
| V | V | **V ✓** | V ✓ | 155/107/119 | +1/+1/+1 |
|
||||||
|
| X | X | **X ✓** | X ✓ | 131/81/135 | +1/+3/+1 |
|
||||||
|
| O | O | **O ✓** | O ✓ | 115/143/123 | +1/+1/+1 |
|
||||||
|
|
||||||
|
- 真值定高 **6/6 断言通过**;关键点定高一致率 **6/6(100%)**(参考值,样本为合成图)。
|
||||||
|
- 阈值(首版经验值,**待顾问校准**):肩/臀 >1.05 → V;<0.95 → A;腰 <0.80×min(肩,臀) → X;腰 >1.02×max(肩,臀) → O;否则 H。判定顺序 O → X → V → A → H。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、RTMPose 在合成剪影上的关键点质量(重要发现)
|
||||||
|
|
||||||
|
RTMPose 关键点在合成剪影上**定位偏差可用但置信度极低**:rtmpose-s 肩线偏差 +12px、臀线偏差 −28.5px(画布高 512px),但平均置信度仅 0.003(SimCC 分布近乎平坦)。已排除预处理口径因素(RGB/BGR、0-255/0-1 归一化四种组合结果几乎一致),结论是**域差异**:模型在真实人像上训练,对无纹理几何剪影没有信心。对本 SPIKE 的处理:
|
||||||
|
|
||||||
|
- 断言路径用「真值定高」(任务文档明确允许:*"用 3.2 的关键点(或合成图直接已知的真值框)定肩线/腰线/臀线高度"*),KP 路径仅统计参考一致率;
|
||||||
|
- 对 Sprint 1 的含义:**RTMPose 的关键点质量必须在真实照片上重新验证**,合成图不能作为其可用性依据;体型管线的关键点依赖是当前最大不确定性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、对 Sprint 1 正式验证的建议
|
||||||
|
|
||||||
|
1. **T-P0-16(RTMPose 耗时)**:CPU 数字已可决策(s 档 3ms 级全链路)。还需要补两件事:① 生产链路要前置**人体检测器**(RTMPose 是 top-down),建议补测 YOLOv8n-ONNX 或 RTMO 一体化模型的 CPU 耗时;② 若上传链路要同步姿态,当前 CPU 数字已够;无需 GPU。
|
||||||
|
2. **T-P0-12(抠图质检 ≥90%)**:需要 **200~500 张真实全身照**(覆盖多背景/光线/姿态),人工标注粗前景 GT,指标建议 IoU ≥0.90 或边界 F-measure,先定义「单张通过」标准再统计通过率。成本决策点:若 CPU 3.1s/张不可接受,候选降档(按优先级)——任务异步化(现有 task 架构,零成本)→ BiRefNet fp16 / 512×512 变体(候选已在 download_models.py 链里)→ GPU EP。
|
||||||
|
3. **TD-06(体型测算 ≥80% 一致率)**:需要顾问轨 **≥100 人体型标注**(H/A/V/O/X 五类尽量均衡)+ 每人至少一张标准站姿全身照;阈值校准建议用分位数/ROC 而非拍脑袋值;**前置依赖**是 RTMPose 在真实照片上的关键点质量验证(§三)。腰线代理(肩臀中点)需在标注数据上复核落点。
|
||||||
|
4. **数据合规**:真实人体照片进入验证集前需走用户授权/合成替代评估;本 SPIKE 已验证「合成图 + 真值」开发范式可行,阈值调参阶段可继续用合成图,终验必须真实分布。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、使用的模型与版本(models/manifest.json)
|
||||||
|
|
||||||
|
| 用途 | 模型 | 仓库 @ revision | 变体 | 大小 |
|
||||||
|
| --- | --- | --- | --- | ---: |
|
||||||
|
| 姿态(基准主档) | rtmpose-s.onnx | `bukuroo/RTMPose-ONNX` @ `a6e9fb8a` | RTMPose-s body COCO-17 SimCC,输入 256×192 | 20.9MB |
|
||||||
|
| 姿态(对比档) | rtmpose-m.onnx | `bukuroo/RTMPose-ONNX` @ `a6e9fb8a` | RTMPose-m 同上 | 51.8MB |
|
||||||
|
| 抠图 | birefnet_lite.onnx | `onnx-community/BiRefNet_lite-ONNX` @ `de15b22b` | fp32,输入 1024×1024,ImageNet 归一化 | 213.6MB |
|
||||||
|
|
||||||
|
下载通道:`https://hf-mirror.com`(国内镜像优先,实测 4~12MB/s,总 ~290MB,未触发 10 分钟超时纪律)。
|
||||||
|
|
||||||
|
## 六、降级路径与偏差说明
|
||||||
|
|
||||||
|
- **实现并验证的降级链**(本次全部未触发,代码路径已在脚本中就位):姿态 `bukuroo rtmpose-s` → (失败)`cv2.HOG` 关键点占位;抠图 `BiRefNet_lite fp32` → `fp16` → `briaai/RMBG-1.4` → (仍失败)Otsu 二值化占位。脚本对每级失败均打印 `[降级]` 日志并在输出 JSON 记 `model_source`。
|
||||||
|
- **偏差 1(依赖)**:任务文档建议用 `huggingface_hub.snapshot_download`,但该包不在 spike extra 且本批禁止新增依赖(协作规程 §4.2),改用 stdlib `urllib` 直连 `{endpoint}/{repo}/resolve/main/{file}`(同一 HTTP 语义,支持 30x 与 Range 断点续传)。已在脚本 docstring 声明。
|
||||||
|
- **偏差 2(模型选型)**:文档首选 `PINTO0309/PINTO_model_zoo` 的 rtmpose,但该仓库为巨型混合仓库(API 兄弟列表无法按"body 17/26 keypoint 小模型"脚本化定位);`dhkim0115/rtmpose-onnx` 经 API 探查不存在;最终选社区导出 `bukuroo/RTMPose-ONNX`(17 点 COCO、SimCC、256×192,符合文档"body 小模型"要求)。BiRefNet 官方仓 `ZhengPeng7/BiRefNet` 只有 safetensors 无 ONNX,采用 `onnx-community` 的 ONNX 导出(文档列举的备选之一)。
|
||||||
|
- **偏差 3(GPU 档)**:nvidia-smi 可见 RTX 5090,但 venv 的 onnxruntime 为 CPU 构建(providers 无 CUDA),按任务文档「不装 GPU 版 ORT、探测失败不折腾」只报 CPU 档。
|
||||||
|
- **口径调整**:BiRefNet 单张 >3s,正式次数由 20 自适应降为 10(脚本内 `adaptive_note` 字段注明);RTMPose 基准严格按预热 3 + 正式 20。
|
||||||
|
|
||||||
|
## 七、复现方式(worktree 根目录)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/spikes/download_models.py # 联网下载(已缓存则秒过,唯一联网环节)
|
||||||
|
.venv/bin/python scripts/spikes/rtmpose_benchmark.py # Q1:耗时基准 + 关键点
|
||||||
|
.venv/bin/python scripts/spikes/birefnet_matting.py # Q2:代理质检指标
|
||||||
|
.venv/bin/python scripts/spikes/body_measure_prototype.py # Q3:体型分类硬断言
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:数字与 JSON 落在 `scripts/spikes/out/`(已 gitignore),掩膜/抠图预览同目录可人工核验。
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""T9 SPIKE · BiRefNet ONNX 抠图管线 + 代理质检指标(T-P0-12 初步口径)。
|
||||||
|
|
||||||
|
代理指标(注意:这是代理口径,不是 T-P0-12 的正式验收——正式验收需要真实素材 +
|
||||||
|
人工标注的通过率,本 SPIKE 只建管线并给少量合成样图的主观核验依据):
|
||||||
|
- 前景像素占比(合理区间 15%~60%);
|
||||||
|
- 最大连通域占前景比(>0.9 视为「主体单一完整」);
|
||||||
|
- 边缘羽化度(0.05<soft<0.95 过渡带面积 / 边界周长 ≈ 平均过渡带宽度 px);
|
||||||
|
- 单张耗时(预热 3 + 正式 20 次;单张 >3s 时自动降为 10 次并注明)。
|
||||||
|
|
||||||
|
输入:合成人形图(纯色背景 + 渐变背景变体,不涉及真实个人数据);
|
||||||
|
降级:models/ 下无抠图模型时用 Otsu 二值化占位(硬边缘,无羽化,报告需注明);
|
||||||
|
输出:out/ 下掩膜图 + 抠图预览(gitignore,报告只留指标)+ matting_metrics.json。
|
||||||
|
|
||||||
|
用法:.venv/bin/python scripts/spikes/birefnet_matting.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
from synthetic import ASSETS_DIR, ensure_assets
|
||||||
|
from vision_common import MATTING_CHAIN, MODELS_DIR, OUT_DIR, bench, dump_json, load_session
|
||||||
|
|
||||||
|
# ImageNet 归一化(onnx-community 导出的 BiRefNet 系列 preprocessor_config 口径)
|
||||||
|
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||||
|
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||||
|
|
||||||
|
# 代理质检阈值(T-P2-07 质检雏形口径,非正式验收)
|
||||||
|
FG_RATIO_RANGE = (0.15, 0.60) # 前景占比合理区间
|
||||||
|
LARGEST_CC_OK = 0.90 # 最大连通域占前景比阈值
|
||||||
|
SOFT_BAND = (0.05, 0.95) # 软边过渡带判定区间
|
||||||
|
SLOW_RUNS = 10 # 单张 >3s 时的正式次数(默认 20)
|
||||||
|
|
||||||
|
_session: ort.InferenceSession | None = None
|
||||||
|
_model_source: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session() -> ort.InferenceSession | None:
|
||||||
|
"""按候选链加载第一个可用模型(加载失败自动降级到下一候选)。"""
|
||||||
|
global _session, _model_source
|
||||||
|
if _model_source is not None:
|
||||||
|
return _session
|
||||||
|
for name in MATTING_CHAIN:
|
||||||
|
path = MODELS_DIR / name
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_session = load_session(path)
|
||||||
|
_model_source = name
|
||||||
|
print(f"[模型] 使用 {name}({path.stat().st_size / 1048576:.1f} MB)")
|
||||||
|
return _session
|
||||||
|
except Exception as exc: # SPIKE:逐候选降级,失败原因留痕后换下一个
|
||||||
|
print(f"[降级] {name} 加载失败:{exc}")
|
||||||
|
_session = None
|
||||||
|
_model_source = "otsu-fallback"
|
||||||
|
print("[降级] 无可用抠图模型,使用 Otsu 二值化占位(硬边缘、无羽化,报告需注明)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess(img_bgr: np.ndarray, size_wh: tuple[int, int]) -> np.ndarray:
|
||||||
|
resized = cv2.resize(img_bgr, size_wh, interpolation=cv2.INTER_LINEAR)
|
||||||
|
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
||||||
|
rgb = (rgb - IMAGENET_MEAN) / IMAGENET_STD
|
||||||
|
return np.ascontiguousarray(np.transpose(rgb, (2, 0, 1))[None])
|
||||||
|
|
||||||
|
|
||||||
|
def build_person_mask(img_bgr: np.ndarray) -> tuple[np.ndarray, str]:
|
||||||
|
"""抠图主入口:返回 (软掩膜 float32 HxW ∈ [0,1] 与原图同尺寸, 来源标签)。
|
||||||
|
|
||||||
|
无模型时走 Otsu:人体比背景暗 → BINARY_INV 使人体=1。body_measure_prototype
|
||||||
|
也复用本函数,保证两条 spike 的掩膜口径一致。
|
||||||
|
"""
|
||||||
|
session = _get_session()
|
||||||
|
if session is None:
|
||||||
|
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
||||||
|
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||||
|
thresh, _ = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||||
|
return thresh.astype(np.float32) / 255.0, "otsu-fallback"
|
||||||
|
inp = session.get_inputs()[0]
|
||||||
|
size_wh = (
|
||||||
|
inp.shape[3] if isinstance(inp.shape[3], int) else 1024,
|
||||||
|
inp.shape[2] if isinstance(inp.shape[2], int) else 1024,
|
||||||
|
)
|
||||||
|
tensor = _preprocess(img_bgr, size_wh)
|
||||||
|
logits = session.run(None, {inp.name: tensor})[0]
|
||||||
|
soft_small = 1.0 / (1.0 + np.exp(-np.clip(logits[0, 0].astype(np.float32), -30.0, 30.0)))
|
||||||
|
soft = cv2.resize(
|
||||||
|
soft_small, (img_bgr.shape[1], img_bgr.shape[0]), interpolation=cv2.INTER_LINEAR
|
||||||
|
)
|
||||||
|
assert _model_source is not None
|
||||||
|
return soft, _model_source
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_metrics(img_bgr: np.ndarray, soft: np.ndarray) -> dict[str, Any]:
|
||||||
|
"""T-P0-12 代理质检指标(合成图口径,正式验收需真实素材)。"""
|
||||||
|
binary = (soft > 0.5).astype(np.uint8)
|
||||||
|
fg = int(binary.sum())
|
||||||
|
fg_ratio = fg / binary.size
|
||||||
|
num_labels, _, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
||||||
|
largest = int(stats[1:, cv2.CC_STAT_AREA].max()) if num_labels > 1 else 0
|
||||||
|
largest_cc_ratio = largest / fg if fg else 0.0
|
||||||
|
# 边缘羽化:过渡带面积 / 边界周长 ≈ 平均过渡带宽度(px)
|
||||||
|
band = ((soft > SOFT_BAND[0]) & (soft < SOFT_BAND[1])).astype(np.uint8)
|
||||||
|
band_area = int(band.sum())
|
||||||
|
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
||||||
|
perimeter = float(sum(cv2.arcLength(c, True) for c in contours)) or 1.0
|
||||||
|
return {
|
||||||
|
"fg_ratio": round(fg_ratio, 4),
|
||||||
|
"fg_ratio_in_range": FG_RATIO_RANGE[0] <= fg_ratio <= FG_RATIO_RANGE[1],
|
||||||
|
"largest_cc_ratio": round(largest_cc_ratio, 4),
|
||||||
|
"largest_cc_ok": largest_cc_ratio > LARGEST_CC_OK,
|
||||||
|
"feather_band_px": round(band_area / perimeter, 2),
|
||||||
|
"band_area_px": band_area,
|
||||||
|
"boundary_perimeter_px": round(perimeter, 1),
|
||||||
|
"components": int(num_labels - 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bench_latency(img_bgr: np.ndarray) -> dict[str, Any]:
|
||||||
|
"""单张全链路耗时(预处理+推理+sigmoid+resize 回原尺寸),口径同 rtmpose。"""
|
||||||
|
|
||||||
|
def fn() -> tuple[np.ndarray, str]:
|
||||||
|
return build_person_mask(img_bgr)
|
||||||
|
|
||||||
|
fn() # 预估单张耗时以自适应正式次数(BiRefNet 1024² 在 CPU 上可能较慢)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
fn()
|
||||||
|
single = time.perf_counter() - t0
|
||||||
|
runs = SLOW_RUNS if single > 3.0 else 20
|
||||||
|
stats = bench(fn, warmup=3, runs=runs)
|
||||||
|
stats["adaptive_note"] = f"单张约 {single:.2f}s → 正式 {runs} 次"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _save_outputs(stem: str, img: np.ndarray, soft: np.ndarray) -> None:
|
||||||
|
"""掩膜与抠图预览落盘(out/ 已 gitignore,仅作人工核验,不入库)。"""
|
||||||
|
cv2.imwrite(str(OUT_DIR / f"mask_{stem}.png"), (soft * 255).astype(np.uint8))
|
||||||
|
alpha = soft[..., None]
|
||||||
|
cutout = (img.astype(np.float32) * alpha + 255.0 * (1.0 - alpha)).astype(np.uint8)
|
||||||
|
cv2.imwrite(str(OUT_DIR / f"cutout_{stem}.png"), cutout)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ensure_assets()
|
||||||
|
images = {
|
||||||
|
"synthetic_person": ASSETS_DIR / "synthetic_person.png",
|
||||||
|
"synthetic_person_grad": ASSETS_DIR / "synthetic_person_grad.png",
|
||||||
|
}
|
||||||
|
results: dict[str, Any] = {}
|
||||||
|
for stem, path in images.items():
|
||||||
|
img = cv2.imread(str(path))
|
||||||
|
soft, source = build_person_mask(img)
|
||||||
|
metrics = proxy_metrics(img, soft)
|
||||||
|
print(f"\n==== {stem}({source})====")
|
||||||
|
print(
|
||||||
|
f" 前景占比 {metrics['fg_ratio']:.4f}(区间 {FG_RATIO_RANGE})"
|
||||||
|
f" → {'OK' if metrics['fg_ratio_in_range'] else 'OUT'}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" 最大连通域/前景 {metrics['largest_cc_ratio']:.4f}(>{LARGEST_CC_OK})"
|
||||||
|
f" → {'OK' if metrics['largest_cc_ok'] else 'OUT'};连通域数 {metrics['components']}"
|
||||||
|
)
|
||||||
|
print(f" 边缘羽化带 {metrics['feather_band_px']}px(过渡带 {metrics['band_area_px']}px²)")
|
||||||
|
latency = _bench_latency(img)
|
||||||
|
print(
|
||||||
|
f" 耗时 runs={latency['runs']} 均值={latency['mean_s'] * 1000:.0f}ms "
|
||||||
|
f"P95={latency['p95_s'] * 1000:.0f}ms 最大={latency['max_s'] * 1000:.0f}ms"
|
||||||
|
f"({latency['adaptive_note']})"
|
||||||
|
)
|
||||||
|
_save_outputs(stem, img, soft)
|
||||||
|
results[stem] = {"model_source": source, "metrics": metrics, "latency": latency}
|
||||||
|
|
||||||
|
dump_json(OUT_DIR / "matting_metrics.json", results)
|
||||||
|
print(f"\n结果已写入 {OUT_DIR / 'matting_metrics.json'}(掩膜/抠图预览在同目录)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""T9 SPIKE · TD-06 体型测算原型:定高(关键点/真值)+ 掩膜宽度剖面 → H/A/V/O/X 初判。
|
||||||
|
|
||||||
|
管线:肩/腰/臀三线高度 → 在抠图掩膜上取对应行宽度剖面 → 肩宽/腰宽/臀宽比值 →
|
||||||
|
规则初判。两条定高路径:
|
||||||
|
- 真值定高(合成图生成时已知三线高度):对全部体型变体【硬断言】分类正确——
|
||||||
|
这是任务文档 §3.4 要求的「已知真值形状的分类正确」最小一致率验证;
|
||||||
|
- 关键点定高(RTMPose 肩/臀关键点;COCO-17 无腰点,腰线取肩臀中点作代理):
|
||||||
|
同样输出分类并统计与真值的一致率。≥80% 一致率的正式验证需顾问轨人工标注,
|
||||||
|
本任务不承诺(见 REPORT.md)。
|
||||||
|
|
||||||
|
阈值全部为常量——首版经验值,【待顾问校准】。
|
||||||
|
用法:.venv/bin/python scripts/spikes/body_measure_prototype.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from birefnet_matting import build_person_mask
|
||||||
|
from synthetic import ASSETS_DIR, VARIANTS, ensure_assets
|
||||||
|
from vision_common import (
|
||||||
|
IDX_HIP,
|
||||||
|
IDX_SHOULDER,
|
||||||
|
MODELS_DIR,
|
||||||
|
OUT_DIR,
|
||||||
|
POSE_CHAIN,
|
||||||
|
RTMPoseRunner,
|
||||||
|
dump_json,
|
||||||
|
load_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 体型初判阈值(首版经验值,待顾问轨校准) ────────────────────────────────
|
||||||
|
SR_V = 1.05 # 肩/臀 > 1.05 → V(倒三角)
|
||||||
|
SR_A = 0.95 # 肩/臀 < 0.95 → A(梨形)
|
||||||
|
X_WAIST = 0.80 # 腰 < 0.80×min(肩,臀) → X(沙漏:腰明显收窄)
|
||||||
|
O_WAIST = 1.02 # 腰 > 1.02×max(肩,臀) → O(中段最大)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_width(binary: np.ndarray, y: int) -> float:
|
||||||
|
"""掩膜在 y 行的宽度剖面:取 y-1/y/y+1 三行前景跨度的中位数(抗单行毛刺)。"""
|
||||||
|
widths: list[float] = []
|
||||||
|
for row_y in (y - 1, y, y + 1):
|
||||||
|
if not 0 <= row_y < binary.shape[0]:
|
||||||
|
continue
|
||||||
|
xs = np.flatnonzero(binary[row_y])
|
||||||
|
if xs.size:
|
||||||
|
widths.append(float(xs[-1] - xs[0] + 1))
|
||||||
|
if not widths:
|
||||||
|
return 0.0
|
||||||
|
return float(np.median(widths))
|
||||||
|
|
||||||
|
|
||||||
|
def measure(binary: np.ndarray, line_y: dict[str, int]) -> dict[str, float]:
|
||||||
|
"""按三线高度取宽度剖面 → 肩宽/腰宽/臀宽(px)。"""
|
||||||
|
return {name: profile_width(binary, y) for name, y in line_y.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def classify(widths: dict[str, float]) -> str:
|
||||||
|
"""规则初判:先判中段(O/X),再判肩臀比(V/A),否则 H。"""
|
||||||
|
if min(widths.values()) <= 0.0:
|
||||||
|
return "INVALID" # 掩膜在该线无前景:管线异常,硬断言会失败(这是期望行为)
|
||||||
|
shoulder, waist, hip = widths["shoulder"], widths["waist"], widths["hip"]
|
||||||
|
sr = shoulder / hip
|
||||||
|
if waist > O_WAIST * max(shoulder, hip):
|
||||||
|
return "O"
|
||||||
|
if waist < X_WAIST * min(shoulder, hip):
|
||||||
|
return "X"
|
||||||
|
if sr > SR_V:
|
||||||
|
return "V"
|
||||||
|
if sr < SR_A:
|
||||||
|
return "A"
|
||||||
|
return "H"
|
||||||
|
|
||||||
|
|
||||||
|
def heights_from_keypoints(kps: np.ndarray) -> dict[str, int]:
|
||||||
|
"""关键点定高:肩线=双肩均值,臀线=双髋均值,腰线=肩臀中点(COCO-17 无腰点)。"""
|
||||||
|
y_sh = float(np.mean(kps[list(IDX_SHOULDER), 1]))
|
||||||
|
y_hip = float(np.mean(kps[list(IDX_HIP), 1]))
|
||||||
|
y_waist = (y_sh + y_hip) / 2.0
|
||||||
|
return {"shoulder": round(y_sh), "waist": round(y_waist), "hip": round(y_hip)}
|
||||||
|
|
||||||
|
|
||||||
|
def _try_pose_runner() -> RTMPoseRunner | None:
|
||||||
|
for name in POSE_CHAIN:
|
||||||
|
path = MODELS_DIR / name
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
runner = RTMPoseRunner(load_session(path))
|
||||||
|
print(f"[模型] 关键点定高使用 {name}")
|
||||||
|
return runner
|
||||||
|
except Exception as exc: # SPIKE:加载失败降级,真值定高路径不受影响
|
||||||
|
print(f"[降级] {name} 初始化失败:{exc}")
|
||||||
|
print("[降级] 无 RTMPose 模型,关键点定高路径跳过(真值定高路径不受影响)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
gt = ensure_assets()
|
||||||
|
pose_runner = _try_pose_runner()
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for shape in VARIANTS:
|
||||||
|
meta = gt["variants"][shape.key]
|
||||||
|
img = cv2.imread(str(ASSETS_DIR / "variants" / f"{shape.key}.png"))
|
||||||
|
soft, mask_source = build_person_mask(img)
|
||||||
|
binary = (soft > 0.5).astype(np.uint8)
|
||||||
|
|
||||||
|
# 路径一:真值定高(合成图生成时已知)——硬断言分类正确
|
||||||
|
widths_gt = measure(binary, meta["line_y"])
|
||||||
|
pred_gt = classify(widths_gt)
|
||||||
|
assert pred_gt == shape.expect, (
|
||||||
|
f"{shape.key}: 真值定高分类 {pred_gt} != 期望 {shape.expect},宽度={widths_gt}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 路径二:关键点定高(RTMPose;腰线为肩臀中点代理)——统计一致率
|
||||||
|
pred_kp: str | None = None
|
||||||
|
widths_kp: dict[str, float] | None = None
|
||||||
|
if pose_runner is not None:
|
||||||
|
kps = pose_runner.infer(img)
|
||||||
|
widths_kp = measure(binary, heights_from_keypoints(kps))
|
||||||
|
pred_kp = classify(widths_kp)
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"key": shape.key,
|
||||||
|
"expect": shape.expect,
|
||||||
|
"pred_gt": pred_gt,
|
||||||
|
"pred_kp": pred_kp,
|
||||||
|
"gt_ok": pred_gt == shape.expect,
|
||||||
|
"kp_ok": None if pred_kp is None else pred_kp == shape.expect,
|
||||||
|
"widths_gt": widths_gt,
|
||||||
|
"widths_kp": widths_kp,
|
||||||
|
"gt_widths_drawn": meta["gt_widths"],
|
||||||
|
"mask_source": mask_source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n==== 体型初判结果(掩膜来源 {rows[0]['mask_source']})====")
|
||||||
|
print(f" {'变体':>4} {'期望':>4} {'真值定高':>8} {'关键点定高':>10} 宽度(肩/腰/臀, 掩膜测得)")
|
||||||
|
for row in rows:
|
||||||
|
w = row["widths_gt"]
|
||||||
|
kp_txt = row["pred_kp"] or "-"
|
||||||
|
print(
|
||||||
|
f" {row['key']:>4} {row['expect']:>4} {row['pred_gt']:>8} {kp_txt:>10}"
|
||||||
|
f" {w['shoulder']:.0f}/{w['waist']:.0f}/{w['hip']:.0f}px"
|
||||||
|
)
|
||||||
|
gt_pass = sum(1 for r in rows if r["gt_ok"])
|
||||||
|
kp_rows = [r for r in rows if r["kp_ok"] is not None]
|
||||||
|
kp_pass = sum(1 for r in kp_rows if r["kp_ok"])
|
||||||
|
print(f"\n [硬断言] 真值定高 {gt_pass}/{len(rows)} 分类正确(全部通过 = TD-06 原型管线成立)")
|
||||||
|
if kp_rows:
|
||||||
|
print(
|
||||||
|
f" [参考] 关键点定高一致率 {kp_pass}/{len(kp_rows)}"
|
||||||
|
f"({kp_pass / len(kp_rows):.0%};正式 ≥80% 验证需顾问轨人工标注,本任务不承诺)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 掩膜保真度:测得宽度 vs 画图时的几何真值宽度(px 级偏差)
|
||||||
|
print(" [保真度] 掩膜宽度 vs 几何真值(px):")
|
||||||
|
for row in rows:
|
||||||
|
drawn = row["gt_widths_drawn"]
|
||||||
|
w = row["widths_gt"]
|
||||||
|
deltas = [w[k] - drawn[k] for k in ("shoulder", "waist", "hip")]
|
||||||
|
print(f" {row['key']:>4}: 肩{deltas[0]:+.1f} 腰{deltas[1]:+.1f} 臀{deltas[2]:+.1f}")
|
||||||
|
|
||||||
|
dump_json(
|
||||||
|
OUT_DIR / "body_shape_result.json",
|
||||||
|
{
|
||||||
|
"thresholds": {"SR_V": SR_V, "SR_A": SR_A, "X_WAIST": X_WAIST, "O_WAIST": O_WAIST},
|
||||||
|
"gt_pass": gt_pass,
|
||||||
|
"total": len(rows),
|
||||||
|
"kp_pass": kp_pass,
|
||||||
|
"kp_total": len(kp_rows),
|
||||||
|
"rows": rows,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(f"\n结果已写入 {OUT_DIR / 'body_shape_result.json'}")
|
||||||
|
if gt_pass != len(rows):
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""T9 SPIKE · 模型下载脚本(本批唯一允许联网的环节,任务文档 §3.1)。
|
||||||
|
|
||||||
|
为什么不用 huggingface_hub:该包不在 spike extra 依赖中,且协作规程 §4.2 禁止新增
|
||||||
|
依赖,故用 stdlib urllib 直连 `{endpoint}/{repo}/resolve/main/{file}`——与
|
||||||
|
huggingface_hub 底层相同的 HTTP 语义(30x 跳转与 Range 断点续传均支持)。
|
||||||
|
|
||||||
|
行为要点:
|
||||||
|
- 端点顺序:环境变量 HF_ENDPOINT > https://hf-mirror.com(国内镜像优先)> 官方站;
|
||||||
|
- 候选链:每个目标文件按候选依次降级(BiRefNet_lite fp32 → fp16 → RMBG-1.4);
|
||||||
|
- 超时纪律:单文件超过 --budget 秒(默认 600s ≈ 10 分钟)即放弃当前候选换下一个;
|
||||||
|
- 幂等:本地已存在且字节数与远端一致则跳过;.part 断点续传;每候选失败重试 1 次;
|
||||||
|
- 结果(repo/revision/大小/耗时/端点)写入 models/manifest.json,供 REPORT.md 引用。
|
||||||
|
|
||||||
|
用法(在 worktree 根目录):
|
||||||
|
.venv/bin/python scripts/spikes/download_models.py [--only matting] [--budget 600]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_MODELS_DIR = REPO_ROOT / "models"
|
||||||
|
DEFAULT_BUDGET_SECONDS = 600 # 任务文档:单个模型 ~10 分钟即降级
|
||||||
|
CHUNK_BYTES = 1 << 20
|
||||||
|
ATTEMPTS_PER_CANDIDATE = 2 # 首次 + 失败重试一次
|
||||||
|
PROGRESS_EVERY_BYTES = 32 * CHUNK_BYTES
|
||||||
|
UA = "meida-t9-spike/1.0"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Candidate:
|
||||||
|
"""一个候选模型文件的下载描述。"""
|
||||||
|
|
||||||
|
repo: str
|
||||||
|
filename: str
|
||||||
|
dest: str # models/ 下的目标文件名
|
||||||
|
note: str # 变体说明(写入 manifest,REPORT 引用)
|
||||||
|
|
||||||
|
|
||||||
|
# 目标文件 → 候选链(顺序即优先级)。REQUIRED 目标缺失时退出码为 1,
|
||||||
|
# 基准脚本随后走 HOG / Otsu 降级路线(见各脚本的降级分支)。
|
||||||
|
TARGETS: dict[str, tuple[Candidate, ...]] = {
|
||||||
|
"pose-s": (
|
||||||
|
Candidate(
|
||||||
|
repo="bukuroo/RTMPose-ONNX",
|
||||||
|
filename="rtmpose-s.onnx",
|
||||||
|
dest="rtmpose-s.onnx",
|
||||||
|
note="RTMPose-s body(COCO-17, SimCC) 社区 ONNX 导出",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"pose-m": (
|
||||||
|
Candidate(
|
||||||
|
repo="bukuroo/RTMPose-ONNX",
|
||||||
|
filename="rtmpose-m.onnx",
|
||||||
|
dest="rtmpose-m.onnx",
|
||||||
|
note="RTMPose-m body(COCO-17, SimCC),规模-时延对比用(可选)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"matting": (
|
||||||
|
Candidate(
|
||||||
|
repo="onnx-community/BiRefNet_lite-ONNX",
|
||||||
|
filename="onnx/model.onnx",
|
||||||
|
dest="birefnet_lite.onnx",
|
||||||
|
note="BiRefNet_lite fp32 1024x1024(任务文档首选 lite 变体)",
|
||||||
|
),
|
||||||
|
Candidate(
|
||||||
|
repo="onnx-community/BiRefNet_lite-ONNX",
|
||||||
|
filename="onnx/model_fp16.onnx",
|
||||||
|
dest="birefnet_lite_fp16.onnx",
|
||||||
|
note="BiRefNet_lite fp16 降级档(体积约减半)",
|
||||||
|
),
|
||||||
|
Candidate(
|
||||||
|
repo="briaai/RMBG-1.4",
|
||||||
|
filename="onnx/model.onnx",
|
||||||
|
dest="rmbg14.onnx",
|
||||||
|
note="RMBG-1.4 fp32 末级降级(非 BiRefNet,需在报告注明)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
REQUIRED = ("pose-s", "matting")
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoints() -> list[str]:
|
||||||
|
"""镜像优先:HF_ENDPOINT 可覆盖,默认 hf-mirror.com,再兜底官方站。"""
|
||||||
|
eps = []
|
||||||
|
for ep in (
|
||||||
|
os.environ.get("HF_ENDPOINT", "").rstrip("/"),
|
||||||
|
"https://hf-mirror.com",
|
||||||
|
"https://huggingface.co",
|
||||||
|
):
|
||||||
|
if ep and ep not in eps:
|
||||||
|
eps.append(ep)
|
||||||
|
return eps
|
||||||
|
|
||||||
|
|
||||||
|
def _url(endpoint: str, cand: Candidate) -> str:
|
||||||
|
return f"{endpoint}/{cand.repo}/resolve/main/{cand.filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_revision(endpoint: str, repo: str) -> str:
|
||||||
|
"""通过 model API 拿当前 revision(commit sha),失败记 unknown。"""
|
||||||
|
req = urllib.request.Request(f"{endpoint}/api/models/{repo}", headers={"User-Agent": UA})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
data = json.load(resp)
|
||||||
|
return str(data.get("sha", "unknown"))
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _download_once(
|
||||||
|
endpoint: str,
|
||||||
|
cand: Candidate,
|
||||||
|
models_dir: Path,
|
||||||
|
deadline: float,
|
||||||
|
) -> bool:
|
||||||
|
"""单次下载(支持 .part 续传)。超预算/网络错误返回 False,由外层重试或降级。"""
|
||||||
|
partial = models_dir / f"{cand.dest}.part"
|
||||||
|
offset = partial.stat().st_size if partial.exists() else 0
|
||||||
|
headers = {"User-Agent": UA}
|
||||||
|
if offset:
|
||||||
|
headers["Range"] = f"bytes={offset}-"
|
||||||
|
req = urllib.request.Request(_url(endpoint, cand), headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp, open(partial, "ab") as fh:
|
||||||
|
content_length = resp.headers.get("Content-Length")
|
||||||
|
total = int(content_length) + offset if content_length else None
|
||||||
|
last_bytes = 0
|
||||||
|
last_time = time.perf_counter()
|
||||||
|
while True:
|
||||||
|
if time.perf_counter() > deadline:
|
||||||
|
print(" [超时] 超过下载预算,放弃当前候选", flush=True)
|
||||||
|
return False
|
||||||
|
chunk = resp.read(CHUNK_BYTES)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
fh.write(chunk)
|
||||||
|
offset += len(chunk)
|
||||||
|
now = time.perf_counter()
|
||||||
|
if offset - last_bytes >= PROGRESS_EVERY_BYTES or now - last_time > 10.0:
|
||||||
|
total_str = f"/{total / 1048576:.1f}MB" if total else ""
|
||||||
|
print(f" {offset / 1048576:.1f}MB{total_str} ...", flush=True)
|
||||||
|
last_bytes = offset
|
||||||
|
last_time = now
|
||||||
|
partial.replace(models_dir / cand.dest)
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 416 and partial.exists(): # Range 起点已到文件尾 = 实际已下完
|
||||||
|
partial.replace(models_dir / cand.dest)
|
||||||
|
return True
|
||||||
|
print(f" [HTTP {exc.code}] {exc.reason}", flush=True)
|
||||||
|
return False
|
||||||
|
except OSError as exc: # 含 socket.timeout / 连接重置:保留 .part 供续传
|
||||||
|
print(f" [网络错误] {exc}", flush=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_cached(cand: Candidate, dest: Path, manifest: dict[str, Any]) -> None:
|
||||||
|
"""缓存命中也补全 manifest(repo/revision 供 REPORT 引用;离线时 revision 记 unknown)。"""
|
||||||
|
entry = manifest.get(cand.dest, {})
|
||||||
|
manifest[cand.dest] = {
|
||||||
|
"repo": entry.get("repo", cand.repo),
|
||||||
|
"revision": entry.get("revision") or _repo_revision(_endpoints()[0], cand.repo),
|
||||||
|
"bytes": dest.stat().st_size,
|
||||||
|
"cached": True,
|
||||||
|
"note": cand.note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _try_candidate(
|
||||||
|
cand: Candidate,
|
||||||
|
models_dir: Path,
|
||||||
|
budget_s: int,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""单候选全流程:双端点 × 重试一次,成功写 manifest。"""
|
||||||
|
dest = models_dir / cand.dest
|
||||||
|
for endpoint in _endpoints():
|
||||||
|
deadline = time.perf_counter() + budget_s
|
||||||
|
for attempt in range(1, ATTEMPTS_PER_CANDIDATE + 1):
|
||||||
|
print(f"[下载] {cand.repo}/{cand.filename} <- {endpoint}(第 {attempt} 次)")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
if _download_once(endpoint, cand, models_dir, deadline):
|
||||||
|
manifest[cand.dest] = {
|
||||||
|
"repo": cand.repo,
|
||||||
|
"revision": _repo_revision(endpoint, cand.repo),
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"bytes": dest.stat().st_size,
|
||||||
|
"elapsed_s": round(time.perf_counter() - t0, 1),
|
||||||
|
"cached": False,
|
||||||
|
"note": cand.note,
|
||||||
|
}
|
||||||
|
print(f"[完成] {cand.dest} {manifest[cand.dest]['bytes'] / 1048576:.1f}MB")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_target(
|
||||||
|
name: str,
|
||||||
|
models_dir: Path,
|
||||||
|
budget_s: int,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""目标文件级编排:按候选链降级。返回该目标是否本地可用。"""
|
||||||
|
for cand in TARGETS[name]:
|
||||||
|
# 候选链内任何一个目标文件已在本地(含上次跑过其它候选)都算满足
|
||||||
|
dest = models_dir / cand.dest
|
||||||
|
if dest.exists() and dest.stat().st_size > 0:
|
||||||
|
print(f"[缓存] {name}: {cand.dest} 已在本地")
|
||||||
|
_mark_cached(cand, dest, manifest)
|
||||||
|
return True
|
||||||
|
if _try_candidate(cand, models_dir, budget_s, manifest):
|
||||||
|
return True
|
||||||
|
print(f"[降级] {name} 的候选 {cand.repo}/{cand.filename} 失败,尝试下一候选")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="T9 SPIKE 模型下载(唯一联网环节)")
|
||||||
|
parser.add_argument("--models-dir", type=Path, default=DEFAULT_MODELS_DIR)
|
||||||
|
parser.add_argument(
|
||||||
|
"--budget", type=int, default=DEFAULT_BUDGET_SECONDS, help="单文件下载预算(秒)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--only", action="append", choices=sorted(TARGETS), help="只下载指定目标(可多次)"
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
models_dir: Path = args.models_dir
|
||||||
|
models_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest_path = models_dir / "manifest.json"
|
||||||
|
manifest: dict[str, Any] = {}
|
||||||
|
if manifest_path.exists():
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
selected = args.only or sorted(TARGETS)
|
||||||
|
missing_required = False
|
||||||
|
for name in selected:
|
||||||
|
ok = ensure_target(name, models_dir, args.budget, manifest)
|
||||||
|
required = name in REQUIRED
|
||||||
|
if not ok:
|
||||||
|
print(f"[失败] {name}:全部候选未取到({'必需' if required else '可选'})")
|
||||||
|
missing_required |= required
|
||||||
|
|
||||||
|
dump = dict(sorted(manifest.items()))
|
||||||
|
manifest_path.write_text(json.dumps(dump, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
print(f"\nmanifest 已写入 {manifest_path}")
|
||||||
|
for key, entry in dump.items():
|
||||||
|
repo = entry.get("repo", "?")
|
||||||
|
revision = entry.get("revision", "?")
|
||||||
|
print(f" {key}: {repo}@{revision} cached={entry.get('cached')}")
|
||||||
|
if missing_required:
|
||||||
|
print("\n有必需目标缺失:基准脚本将走降级路线(HOG/Otsu),详见 REPORT.md")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""T9 SPIKE · RTMPose ONNX CPU 单张耗时基准(T-P0-16 验收口径)。
|
||||||
|
|
||||||
|
- 输入:scripts/spikes/assets/synthetic_person.png(synthetic.py 生成的合成人形,
|
||||||
|
不涉及真实人脸/人体数据);
|
||||||
|
- 口径:预热 3 + 正式 20,报「纯模型 session.run」与「预处理+推理+解码」两条链路
|
||||||
|
的均值 / P95 / 单次最大(time.perf_counter);
|
||||||
|
- GPU 档:先探测 nvidia-smi 与 ORT providers,CUDA EP 不可用则只报 CPU(不折腾);
|
||||||
|
- 降级:models/ 下无 RTMPose ONNX 时,用 cv2.HOG 人体检测作关键点占位并在输出声明
|
||||||
|
(HOG 无关键点,仅给检测框 + 按人体比例推的占位点,报告需注明);
|
||||||
|
- 输出:out/rtmpose_bench.json(耗时)与 out/rtmpose_keypoints.json(关键点)。
|
||||||
|
|
||||||
|
用法:.venv/bin/python scripts/spikes/rtmpose_benchmark.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from synthetic import ASSETS_DIR, ensure_assets
|
||||||
|
from vision_common import (
|
||||||
|
COCO17_NAMES,
|
||||||
|
IDX_HIP,
|
||||||
|
IDX_SHOULDER,
|
||||||
|
MODELS_DIR,
|
||||||
|
OUT_DIR,
|
||||||
|
POSE_CHAIN,
|
||||||
|
RTMPoseRunner,
|
||||||
|
bench,
|
||||||
|
dump_json,
|
||||||
|
gpu_probe,
|
||||||
|
load_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# GT 校验容差:合成图人形位置已知,肩/臀线估计偏差在该范围内视为「关键点可用」
|
||||||
|
LINE_TOLERANCE_PX = 15
|
||||||
|
CONF_USABLE = 0.30 # 关键点平均置信度低于该值视为「定位不可信」(软指标,仅提示)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_gpu_verdict(gpu: dict[str, Any]) -> None:
|
||||||
|
print("== GPU 探测 ==")
|
||||||
|
print(f" nvidia-smi: {gpu['nvidia_smi'] or '不可用'}")
|
||||||
|
print(f" ORT providers: {gpu['ort_providers']}")
|
||||||
|
if gpu["cuda_ep_available"]:
|
||||||
|
print(" 结论:CUDA EP 可用(但本 spike 约定只装 CPU 版 ORT,仅报 CPU 档)")
|
||||||
|
else:
|
||||||
|
print(" 结论:CUDA EP 不可用 → 只测 CPU 档(任务文档:探测失败不折腾)")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_keypoints(name: str, kps: np.ndarray) -> None:
|
||||||
|
print(f"\n== {name} 关键点(画布坐标 {kps.shape[0]} 点)==")
|
||||||
|
print(f" {'idx':>3} {'name':<14} {'x':>6} {'y':>6} {'conf':>6}")
|
||||||
|
for i, (x, y, c) in enumerate(kps):
|
||||||
|
kp_name = COCO17_NAMES[i] if i < len(COCO17_NAMES) else f"kp{i}"
|
||||||
|
print(f" {i:>3} {kp_name:<14} {x:>6.1f} {y:>6.1f} {c:>6.3f}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_against_gt(name: str, kps: np.ndarray, gt_std: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""合成图真值校验:肩线/臀线高度与置信度(RTMPose 是否真的「看懂」了人形)。"""
|
||||||
|
gt_lines: dict[str, int] = gt_std["line_y"]
|
||||||
|
y_sh = float(np.mean(kps[list(IDX_SHOULDER), 1]))
|
||||||
|
y_hip = float(np.mean(kps[list(IDX_HIP), 1]))
|
||||||
|
d_sh = y_sh - gt_lines["shoulder"]
|
||||||
|
d_hip = y_hip - gt_lines["hip"]
|
||||||
|
conf_mean = float(np.mean(kps[:, 2]))
|
||||||
|
usable = (
|
||||||
|
abs(d_sh) <= LINE_TOLERANCE_PX
|
||||||
|
and abs(d_hip) <= LINE_TOLERANCE_PX
|
||||||
|
and conf_mean >= CONF_USABLE
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" [GT 校验] 肩线偏差 {d_sh:+.1f}px(真值 {gt_lines['shoulder']}),"
|
||||||
|
f"臀线偏差 {d_hip:+.1f}px(真值 {gt_lines['hip']}),平均置信度 {conf_mean:.3f}"
|
||||||
|
f" → {'可用' if usable else '定位不可信(降级口径见 REPORT)'}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"shoulder_line_delta_px": round(d_sh, 1),
|
||||||
|
"hip_line_delta_px": round(d_hip, 1),
|
||||||
|
"conf_mean": round(conf_mean, 3),
|
||||||
|
"keypoints_usable": usable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bench_model(runner: RTMPoseRunner, tensor: np.ndarray, img: np.ndarray) -> dict[str, Any]:
|
||||||
|
def fn_model() -> None:
|
||||||
|
runner.session.run(None, {runner.input_name: tensor})
|
||||||
|
|
||||||
|
def fn_full() -> None:
|
||||||
|
runner.infer(img)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"model_only": bench(fn_model, warmup=3, runs=20),
|
||||||
|
"full_pipeline": bench(fn_full, warmup=3, runs=20),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hog_fallback(img: np.ndarray) -> int:
|
||||||
|
"""无 RTMPose 时的降级路径:HOG 人体检测作关键点占位(报告必须注明此口径)。"""
|
||||||
|
print("\n== 降级路径:cv2.HOG 人体检测(关键点占位,非 RTMPose)==")
|
||||||
|
hog = cv2.HOGDescriptor()
|
||||||
|
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
|
||||||
|
|
||||||
|
def fn() -> None:
|
||||||
|
hog.detectMultiScale(img, winStride=(8, 8), padding=(4, 4), scale=1.05)
|
||||||
|
|
||||||
|
stats = bench(fn, warmup=2, runs=10)
|
||||||
|
found, _weights = hog.detectMultiScale(img, winStride=(8, 8), padding=(4, 4), scale=1.05)
|
||||||
|
if len(found):
|
||||||
|
x, y, w, h = found[0]
|
||||||
|
print(f" 检测到人体框: x={x} y={y} w={w} h={h};耗时 {stats}")
|
||||||
|
# 占位关键点:按人体比例从检测框推(HOG 不输出关键点,仅维持管线可跑通)
|
||||||
|
placeholder = {
|
||||||
|
"left_shoulder": [x + 0.2 * w, y + 0.25 * h],
|
||||||
|
"right_shoulder": [x + 0.8 * w, y + 0.25 * h],
|
||||||
|
"left_hip": [x + 0.3 * w, y + 0.55 * h],
|
||||||
|
"right_hip": [x + 0.7 * w, y + 0.55 * h],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(" 未检测到人体:占位关键点不可用")
|
||||||
|
placeholder = {}
|
||||||
|
dump_json(
|
||||||
|
OUT_DIR / "rtmpose_keypoints.json",
|
||||||
|
{
|
||||||
|
"source": "hog-placeholder",
|
||||||
|
"detections": [list(map(int, b)) for b in found],
|
||||||
|
"placeholder": placeholder,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dump_json(OUT_DIR / "rtmpose_bench.json", {"source": "hog-placeholder", "latency": stats})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ensure_assets()
|
||||||
|
img = cv2.imread(str(ASSETS_DIR / "synthetic_person.png"))
|
||||||
|
gt = json.loads((ASSETS_DIR / "ground_truth.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
gpu = gpu_probe()
|
||||||
|
_print_gpu_verdict(gpu)
|
||||||
|
|
||||||
|
available = [name for name in POSE_CHAIN if (MODELS_DIR / name).exists()]
|
||||||
|
if not available:
|
||||||
|
print("[降级] models/ 下无 RTMPose ONNX,改用 cv2.HOG 作关键点占位(见 REPORT)")
|
||||||
|
return _hog_fallback(img)
|
||||||
|
|
||||||
|
results: dict[str, Any] = {
|
||||||
|
"gpu": gpu,
|
||||||
|
"input": "synthetic_person.png (384x512 合成人形)",
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
keypoints_dump: dict[str, Any] = {}
|
||||||
|
for name in available:
|
||||||
|
path = MODELS_DIR / name
|
||||||
|
print(f"\n==== {name}({path.stat().st_size / 1048576:.1f} MB,CPUExecutionProvider)====")
|
||||||
|
runner = RTMPoseRunner(load_session(path))
|
||||||
|
print(f" 输入: {runner.input_name} {runner.input_hw}(H,W)")
|
||||||
|
kps = runner.infer(img)
|
||||||
|
_print_keypoints(name, kps)
|
||||||
|
gt_check = _check_against_gt(name, kps, gt["variants"]["STD"])
|
||||||
|
stats = _bench_model(runner, runner.preprocess(img), img)
|
||||||
|
for lane, s in stats.items():
|
||||||
|
print(
|
||||||
|
f" [{lane}] runs={s['runs']} 均值={s['mean_s'] * 1000:.1f}ms "
|
||||||
|
f"P95={s['p95_s'] * 1000:.1f}ms 最大={s['max_s'] * 1000:.1f}ms"
|
||||||
|
)
|
||||||
|
results["models"][name] = {
|
||||||
|
"input_hw": list(runner.input_hw),
|
||||||
|
"num_keypoints": int(kps.shape[0]),
|
||||||
|
"gt_check": gt_check,
|
||||||
|
"latency": stats,
|
||||||
|
}
|
||||||
|
keypoints_dump[name] = {
|
||||||
|
"names": list(COCO17_NAMES)[: kps.shape[0]],
|
||||||
|
"keypoints": [
|
||||||
|
[round(float(x), 1), round(float(y), 1), round(float(c), 3)] for x, y, c in kps
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_json(OUT_DIR / "rtmpose_bench.json", results)
|
||||||
|
dump_json(OUT_DIR / "rtmpose_keypoints.json", keypoints_dump)
|
||||||
|
print(f"\n结果已写入 {OUT_DIR / 'rtmpose_bench.json'} 与 {OUT_DIR / 'rtmpose_keypoints.json'}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""T9 SPIKE · 合成人形图生成器(纯几何绘制,不使用任何真实人脸/人体数据——合规红线)。
|
||||||
|
|
||||||
|
rtmpose_benchmark / birefnet_matting 的输入图、body_measure_prototype 的体型变体
|
||||||
|
全部由本模块确定性生成(同一参数永远产出同一张图),并把「生成时即已知」的真值
|
||||||
|
(三线高度 / 三线宽度 / 期望体型)写入 ground_truth.json——这就是体型分类一致性
|
||||||
|
验证的最小真值来源。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
||||||
|
|
||||||
|
# ── 画布与几何(像素;画布 384x512,人形居中站立) ────────────────────────────
|
||||||
|
CANVAS_W, CANVAS_H = 384, 512
|
||||||
|
CX = CANVAS_W // 2
|
||||||
|
HEAD_R = 34
|
||||||
|
HEAD_TOP = 26
|
||||||
|
HEAD_CY = HEAD_TOP + HEAD_R
|
||||||
|
NECK_HW = 8
|
||||||
|
Y_SHOULDER, Y_WAIST, Y_HIP = 112, 200, 280
|
||||||
|
LEG_BOTTOM, LEG_GAP = 486, 8
|
||||||
|
ARM_T = 10 # 手臂沿躯干边缘绘制,每侧超出边缘 ARM_T/2 → 三线宽度 ≈ 2×半宽 + ARM_T
|
||||||
|
|
||||||
|
BODY_COLOR: tuple[int, int, int] = (150, 168, 198) # BGR,米肤色调
|
||||||
|
BG_FLAT: tuple[int, int, int] = (242, 242, 242) # 纯色背景
|
||||||
|
GRAD_TOP = np.array([250, 250, 250], dtype=np.float32) # 渐变背景(上亮)
|
||||||
|
GRAD_BOTTOM = np.array([158, 166, 182], dtype=np.float32) # 渐变背景(下暗)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BodyShape:
|
||||||
|
"""体型变体:三处剖面半宽(像素)+ 期望分类(生成时即已知,即一致性断言真值)。"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
expect: str
|
||||||
|
shoulder_hw: int
|
||||||
|
waist_hw: int
|
||||||
|
hip_hw: int
|
||||||
|
|
||||||
|
|
||||||
|
# STD 是 §3.2/§3.3 的标准输入图(中性比例,分类为 H);其余五种覆盖 H/A/V/O/X 全类别
|
||||||
|
VARIANTS: tuple[BodyShape, ...] = (
|
||||||
|
BodyShape("STD", "H", 60, 50, 60),
|
||||||
|
BodyShape("H", "H", 58, 55, 57),
|
||||||
|
BodyShape("A", "A", 44, 46, 68),
|
||||||
|
BodyShape("V", "V", 72, 48, 54),
|
||||||
|
BodyShape("X", "X", 60, 34, 62),
|
||||||
|
BodyShape("O", "O", 52, 66, 56),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canvas(background: str) -> np.ndarray:
|
||||||
|
if background == "gradient":
|
||||||
|
t = np.linspace(0.0, 1.0, CANVAS_H, dtype=np.float32)[:, None]
|
||||||
|
rows = GRAD_TOP[None, :] * (1.0 - t) + GRAD_BOTTOM[None, :] * t # (H,3) 逐行插值
|
||||||
|
img = np.repeat(rows[:, None, :], CANVAS_W, axis=1).astype(np.uint8)
|
||||||
|
return np.ascontiguousarray(img)
|
||||||
|
return np.full((CANVAS_H, CANVAS_W, 3), BG_FLAT, dtype=np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_figure(shape: BodyShape, background: str = "flat") -> np.ndarray:
|
||||||
|
"""按体型参数绘制人形剪影:头圆 + 颈 + 躯干三段梯形 + 贴边手臂 + 双腿。"""
|
||||||
|
img = _canvas(background)
|
||||||
|
left_edge = [
|
||||||
|
(CX - shape.shoulder_hw, Y_SHOULDER),
|
||||||
|
(CX - shape.waist_hw, Y_WAIST),
|
||||||
|
(CX - shape.hip_hw, Y_HIP),
|
||||||
|
]
|
||||||
|
right_edge = [
|
||||||
|
(CX + shape.shoulder_hw, Y_SHOULDER),
|
||||||
|
(CX + shape.waist_hw, Y_WAIST),
|
||||||
|
(CX + shape.hip_hw, Y_HIP),
|
||||||
|
]
|
||||||
|
poly = np.array([*left_edge, right_edge[2], right_edge[1], right_edge[0]], dtype=np.int32)
|
||||||
|
cv2.fillPoly(img, [poly], BODY_COLOR)
|
||||||
|
# 手臂:沿躯干左右轮廓的粗折线,圆角过渡,对三条剖面线近似均匀加宽 ARM_T/2×2
|
||||||
|
for edge in (left_edge, right_edge):
|
||||||
|
cv2.polylines(
|
||||||
|
img,
|
||||||
|
[np.array(edge, dtype=np.int32)],
|
||||||
|
isClosed=False,
|
||||||
|
color=BODY_COLOR,
|
||||||
|
thickness=ARM_T,
|
||||||
|
lineType=cv2.LINE_8,
|
||||||
|
)
|
||||||
|
# 颈 + 头
|
||||||
|
cv2.rectangle(img, (CX - NECK_HW, HEAD_CY), (CX + NECK_HW, Y_SHOULDER + 6), BODY_COLOR, -1)
|
||||||
|
cv2.circle(img, (CX, HEAD_CY), HEAD_R, BODY_COLOR, -1)
|
||||||
|
# 双腿:臀线以下的两个矩形,总宽 = 臀宽,中间留 LEG_GAP
|
||||||
|
leg_w = shape.hip_hw - LEG_GAP // 2
|
||||||
|
for x0 in (CX - shape.hip_hw, CX + LEG_GAP // 2):
|
||||||
|
cv2.rectangle(img, (x0, Y_HIP - 2), (x0 + leg_w, LEG_BOTTOM), BODY_COLOR, -1)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _ground_truth(shape: BodyShape) -> dict[str, Any]:
|
||||||
|
"""生成时即已知的真值:三线高度、三线宽度(含手臂加宽)、包围盒、期望分类。"""
|
||||||
|
half = max(shape.shoulder_hw, shape.hip_hw)
|
||||||
|
return {
|
||||||
|
"key": shape.key,
|
||||||
|
"expect": shape.expect,
|
||||||
|
"half_widths": {
|
||||||
|
"shoulder": shape.shoulder_hw,
|
||||||
|
"waist": shape.waist_hw,
|
||||||
|
"hip": shape.hip_hw,
|
||||||
|
},
|
||||||
|
"gt_widths": {
|
||||||
|
"shoulder": 2 * shape.shoulder_hw + ARM_T,
|
||||||
|
"waist": 2 * shape.waist_hw + ARM_T,
|
||||||
|
"hip": 2 * shape.hip_hw + ARM_T,
|
||||||
|
},
|
||||||
|
"line_y": {"shoulder": Y_SHOULDER, "waist": Y_WAIST, "hip": Y_HIP},
|
||||||
|
"bbox": [CX - half - ARM_T, HEAD_TOP, CX + half + ARM_T, LEG_BOTTOM],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_assets(assets_dir: Path = ASSETS_DIR) -> dict[str, Any]:
|
||||||
|
"""(重新)生成全部合成图与真值 JSON。确定性输出,可反复执行(幂等)。"""
|
||||||
|
variants_dir = assets_dir / "variants"
|
||||||
|
variants_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
gt: dict[str, Any] = {"canvas": [CANVAS_W, CANVAS_H], "arm_thickness": ARM_T, "variants": {}}
|
||||||
|
for shape in VARIANTS:
|
||||||
|
cv2.imwrite(str(variants_dir / f"{shape.key}.png"), draw_figure(shape, "flat"))
|
||||||
|
gt["variants"][shape.key] = _ground_truth(shape)
|
||||||
|
# §3.2/§3.3 的标准输入:纯色背景 + 渐变背景变体(各一张)
|
||||||
|
cv2.imwrite(str(assets_dir / "synthetic_person.png"), draw_figure(VARIANTS[0], "flat"))
|
||||||
|
cv2.imwrite(str(assets_dir / "synthetic_person_grad.png"), draw_figure(VARIANTS[0], "gradient"))
|
||||||
|
(assets_dir / "ground_truth.json").write_text(
|
||||||
|
json.dumps(gt, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
return gt
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""T9 SPIKE · 共用小工具:基准统计、ORT 会话加载、RTMPose 推理解码封装。
|
||||||
|
|
||||||
|
只服务 scripts/spikes/ 下的四个脚本,不属于生产代码路径;网络访问仅存在于
|
||||||
|
download_models.py(本批唯一允许联网的环节),本模块与基准脚本全部离线可跑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
MODELS_DIR = REPO_ROOT / "models"
|
||||||
|
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
||||||
|
OUT_DIR = Path(__file__).resolve().parent / "out"
|
||||||
|
|
||||||
|
# 模型候选链:与 download_models.py 的 TARGETS 保持一致;全缺失时调用方走降级(HOG/Otsu)
|
||||||
|
POSE_CHAIN: tuple[str, ...] = ("rtmpose-s.onnx", "rtmpose-m.onnx")
|
||||||
|
MATTING_CHAIN: tuple[str, ...] = ("birefnet_lite.onnx", "birefnet_lite_fp16.onnx", "rmbg14.onnx")
|
||||||
|
|
||||||
|
# RTMPose(mmpose 部署口径):RGB 0~255 图像直接减均值除标准差
|
||||||
|
MMPOSE_MEAN = np.array([123.675, 116.28, 103.53], dtype=np.float32)
|
||||||
|
MMPOSE_STD = np.array([58.395, 57.12, 57.375], dtype=np.float32)
|
||||||
|
|
||||||
|
COCO17_NAMES = (
|
||||||
|
"nose",
|
||||||
|
"left_eye",
|
||||||
|
"right_eye",
|
||||||
|
"left_ear",
|
||||||
|
"right_ear",
|
||||||
|
"left_shoulder",
|
||||||
|
"right_shoulder",
|
||||||
|
"left_elbow",
|
||||||
|
"right_elbow",
|
||||||
|
"left_wrist",
|
||||||
|
"right_wrist",
|
||||||
|
"left_hip",
|
||||||
|
"right_hip",
|
||||||
|
"left_knee",
|
||||||
|
"right_knee",
|
||||||
|
"left_ankle",
|
||||||
|
"right_ankle",
|
||||||
|
)
|
||||||
|
# COCO-17 索引(heights_from_keypoints / GT 校验用)
|
||||||
|
IDX_SHOULDER = (5, 6)
|
||||||
|
IDX_HIP = (11, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def bench(fn: Callable[[], Any], warmup: int = 3, runs: int = 20) -> dict[str, Any]:
|
||||||
|
"""预热 warmup 次后正式 runs 次,返回均值/P95/单次最大(秒,perf_counter 口径)。
|
||||||
|
|
||||||
|
T-P0-16 的验收口径就是这里的输出:预热 3 + 正式 20。
|
||||||
|
"""
|
||||||
|
for _ in range(warmup):
|
||||||
|
fn()
|
||||||
|
samples: list[float] = []
|
||||||
|
for _ in range(runs):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
fn()
|
||||||
|
samples.append(time.perf_counter() - t0)
|
||||||
|
arr = np.asarray(samples, dtype=np.float64)
|
||||||
|
return {
|
||||||
|
"runs": runs,
|
||||||
|
"mean_s": round(float(arr.mean()), 4),
|
||||||
|
"p95_s": round(float(np.percentile(arr, 95)), 4),
|
||||||
|
"max_s": round(float(arr.max()), 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_session(path: Path) -> ort.InferenceSession:
|
||||||
|
"""CPU EP 加载 ONNX。spike 约定不装 GPU 版 ORT(CUDA 匹配成本高,见任务文档 §2)。"""
|
||||||
|
return ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
|
||||||
|
|
||||||
|
|
||||||
|
def gpu_probe() -> dict[str, Any]:
|
||||||
|
"""探测 GPU 档可用性。任务文档:探测失败不折腾,只报 CPU。"""
|
||||||
|
info: dict[str, Any] = {"ort_providers": list(ort.get_available_providers())}
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
info["nvidia_smi"] = proc.stdout.strip()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
info["nvidia_smi"] = None
|
||||||
|
info["cuda_ep_available"] = "CUDAExecutionProvider" in info["ort_providers"]
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def dump_json(path: Path, payload: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class RTMPoseRunner:
|
||||||
|
"""RTMPose ONNX(SimCC 输出)最小封装:整图 resize → 推理 → 关键点画布坐标。
|
||||||
|
|
||||||
|
SPIKE 口径说明:自顶向下姿态模型本应由检测器给人体框再裁剪推理;合成图人形
|
||||||
|
占满画布且位置已知,故直接整图缩放(等价于"完美检测框"),把变量集中在耗时
|
||||||
|
测量上。输出 (K,3):画布坐标 x、y + 置信度(x/y 两方向 softmax 峰值的较小者)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: ort.InferenceSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
inp = session.get_inputs()[0]
|
||||||
|
self.input_name = inp.name
|
||||||
|
# 动态维度(字符串)按 1 处理,仅用于确定输入尺寸
|
||||||
|
shape = [d if isinstance(d, int) else 1 for d in inp.shape]
|
||||||
|
if len(shape) != 4:
|
||||||
|
msg = f"非预期的 RTMPose 输入维度: {inp.shape}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
self.input_hw = (shape[2], shape[3]) # (H, W)
|
||||||
|
|
||||||
|
def preprocess(self, img_bgr: np.ndarray) -> np.ndarray:
|
||||||
|
h, w = self.input_hw
|
||||||
|
resized = cv2.resize(img_bgr, (w, h), interpolation=cv2.INTER_LINEAR)
|
||||||
|
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB).astype(np.float32)
|
||||||
|
rgb = (rgb - MMPOSE_MEAN) / MMPOSE_STD
|
||||||
|
chw = np.transpose(rgb, (2, 0, 1))
|
||||||
|
return np.ascontiguousarray(chw[None])
|
||||||
|
|
||||||
|
def infer(self, img_bgr: np.ndarray) -> np.ndarray:
|
||||||
|
"""完整链路:预处理 + session.run + SimCC 解码 → (K,3)。"""
|
||||||
|
tensor = self.preprocess(img_bgr)
|
||||||
|
outputs = self.session.run(None, {self.input_name: tensor})
|
||||||
|
return self._decode(outputs, orig_w=img_bgr.shape[1], orig_h=img_bgr.shape[0])
|
||||||
|
|
||||||
|
def _decode(self, outputs: list[np.ndarray], orig_w: int, orig_h: int) -> np.ndarray:
|
||||||
|
# SimCC 输出为两个 (1,K,Nx)/(1,K,Ny) 一维分布;Nx∝输入宽、Ny∝输入高
|
||||||
|
cand = [o for o in outputs if o.ndim == 3]
|
||||||
|
if len(cand) < 2:
|
||||||
|
msg = f"非 SimCC 输出,形状: {[o.shape for o in outputs]}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
in_h, in_w = self.input_hw
|
||||||
|
a, b = cand[0], cand[1]
|
||||||
|
# 与输入宽成比例的是 x 分布(比较两种配对的残差,取小者)
|
||||||
|
fit_ab = abs(a.shape[2] / in_w - b.shape[2] / in_h)
|
||||||
|
fit_ba = abs(b.shape[2] / in_w - a.shape[2] / in_h)
|
||||||
|
if fit_ba < fit_ab:
|
||||||
|
a, b = b, a
|
||||||
|
x_simcc, y_simcc = a[0], b[0] # (K,Nx) / (K,Ny)
|
||||||
|
scale_x = a.shape[2] / in_w
|
||||||
|
scale_y = b.shape[2] / in_h
|
||||||
|
xs = x_simcc.argmax(axis=1).astype(np.float32) / scale_x
|
||||||
|
ys = y_simcc.argmax(axis=1).astype(np.float32) / scale_y
|
||||||
|
conf = np.minimum(_peak_prob(x_simcc), _peak_prob(y_simcc))
|
||||||
|
kps = np.zeros((xs.shape[0], 3), dtype=np.float32)
|
||||||
|
kps[:, 0] = xs * (orig_w / in_w)
|
||||||
|
kps[:, 1] = ys * (orig_h / in_h)
|
||||||
|
kps[:, 2] = conf
|
||||||
|
return kps
|
||||||
|
|
||||||
|
|
||||||
|
def _peak_prob(simcc: np.ndarray) -> np.ndarray:
|
||||||
|
"""每个关键点一维分布的 softmax 峰值概率,作为置信度代理。"""
|
||||||
|
e = np.exp(simcc - simcc.max(axis=1, keepdims=True))
|
||||||
|
probs = e / e.sum(axis=1, keepdims=True)
|
||||||
|
return probs.max(axis=1)
|
||||||
Reference in New Issue
Block a user