diff --git a/Makefile b/Makefile
index 2a6c4c268..b7d8869e9 100644
--- a/Makefile
+++ b/Makefile
@@ -223,12 +223,16 @@ INFER_TENSOR_CONTRACTS = \
tests/eval_core/test_catalog_integration_status.py \
$(EVALUATION_CPU_CONTRACTS)
EVALUATION_CPU_CONTRACTS = \
+ tests/eval_core/test_worldmembench_official_runner.py \
+ tests/eval_core/test_worldmembench_runtime.py \
tests/evaluation/execution/test_resume_options.py \
tests/evaluation/execution/test_result_integrity.py \
tests/evaluation/execution/test_helper_reuse.py \
tests/evaluation/execution/test_pipeline_boundaries.py \
tests/evaluation/execution/test_generation_artifact_materialization.py \
tests/evaluation/execution/test_shared_scoring_backend.py \
+ tests/evaluation/execution/test_shared_quality.py \
+ tests/evaluation/execution/test_worldmembench_camera.py \
tests/evaluation/execution/test_vbench_appearance_reduction.py \
tests/evaluation/execution/test_worldscore_evaluator_lifecycle.py \
tests/evaluation/metrics/test_scalar_aggregation.py \
diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES
index d8fd06b2e..59bee2f13 100644
--- a/THIRD-PARTY-NOTICES
+++ b/THIRD-PARTY-NOTICES
@@ -6331,3 +6331,38 @@ Wan2.2 support assets retain their Apache-2.0 license.
Original-File: WorldPlay2/WorldPlay2/LICENSE.txt
license: cc-by-nc-4.0
+
+
+WorldMemBench evaluation protocols
+---------------------------------
+Source: https://github.com/ZJU-REAL/WorldMemBench
+Source revision: 1b79840143a98e017891e893b5d3ef75b93078a3
+License: MIT
+Files: worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/
+
+WorldFoundry retains the official sampling, revisit, alignment, aggregation,
+VLM judging and result protocols. CLIP, LAION aesthetic heads and DINO model
+loading use shared WorldFoundry components. Human and object masked DINO
+features share one implementation. Model weights are distributed separately.
+
+MIT License
+
+Copyright (c) 2026 WorldMemBench
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.json b/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.json
index 47df0571e..e969c5e00 100644
--- a/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.json
+++ b/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.json
@@ -63,6 +63,7 @@
"rbench",
"---World Models---",
"worldmodelbench",
+ "worldmembench",
"worldscore",
"worldbench",
"wbench",
diff --git a/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.zh.json b/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.zh.json
index 47df0571e..e969c5e00 100644
--- a/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.zh.json
+++ b/docs/fumadocs/content/docs/evaluation/benchmark-hub/meta.zh.json
@@ -63,6 +63,7 @@
"rbench",
"---World Models---",
"worldmodelbench",
+ "worldmembench",
"worldscore",
"worldbench",
"wbench",
diff --git a/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.mdx b/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.mdx
index 6e8e8943b..6ab70d36f 100644
--- a/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.mdx
+++ b/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.mdx
@@ -17,6 +17,12 @@ WorldFoundry **checks in the metric runtime** (HPSv3, TransNetV2, PAVRM, DreamSi
+Imaging quality uses the official PyIQA `musiq` variant (KonIQ), with its
+checkpoint resolved explicitly through `wbench_quality_metric_assets`.
+`WORLDFOUNDRY_WBENCH_MUSIQ_KONIQ_CKPT` can select an already staged checkpoint.
+Its preprocessing and per-frame normalization retain the official protocol;
+WorldMemBench's MUSIQ-SPAQ is a different metric backend profile.
+
## How to run
```bash
diff --git a/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.zh.mdx b/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.zh.mdx
index 6f7891668..0db067f10 100644
--- a/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.zh.mdx
+++ b/docs/fumadocs/content/docs/evaluation/benchmark-hub/wbench.zh.mdx
@@ -17,6 +17,10 @@ WorldFoundry **检入指标 runtime**(HPSv3、TransNetV2、PAVRM、DreamSim、
+成像质量采用官方 PyIQA `musiq` 版本(KonIQ),通过 `wbench_quality_metric_assets`
+显式解析权重。可用 `WORLDFOUNDRY_WBENCH_MUSIQ_KONIQ_CKPT` 指向已准备的权重。
+预处理和逐帧归一化保留官方协议;WorldMemBench 的 MUSIQ-SPAQ 属于不同的后端配置。
+
## 如何运行
```bash
diff --git a/docs/fumadocs/content/docs/evaluation/benchmark-hub/worldmembench.mdx b/docs/fumadocs/content/docs/evaluation/benchmark-hub/worldmembench.mdx
new file mode 100644
index 000000000..17ab271cb
--- /dev/null
+++ b/docs/fumadocs/content/docs/evaluation/benchmark-hub/worldmembench.mdx
@@ -0,0 +1,104 @@
+---
+title: WorldMemBench
+description: In-tree visual-memory and camera-control evaluation with shared model components.
+---
+
+
+
+## Scope
+
+[WorldMemBench](https://github.com/ZJU-REAL/WorldMemBench) evaluates human,
+object, environment and dynamic memory, camera control, and video quality.
+WorldFoundry runs all twelve official metric protocols in tree, adapted from
+revision `1b79840143a98e017891e893b5d3ef75b93078a3` under MIT. An upstream
+WorldMemBench checkout or Python package is not required.
+
+| Metric ID | Official score field | Direction |
+| --- | --- | --- |
+| `imaging_quality` | `score` | Higher |
+| `aesthetic_quality` | `score` | Higher |
+| `human_identity` | `score` | Higher |
+| `human_appearance` | `score` | Higher |
+| `object_geometry` | `score` | Higher |
+| `object_appearance` | `score` | Higher |
+| `environment_visible_memory` | `score` | Higher |
+| `environment_depth_fidelity` | `depth_spearman` | Higher |
+| `environment_scene_reconstruction` | `reconstruction_score` | Higher |
+| `dynamic_memory` | `score` | Higher |
+| `camera_rotation` | `camera_rotation_rpe_path` | Lower |
+| `camera_translation` | `camera_translation_rpe` | Lower |
+
+Scores retain their official units, including negative depth correlations and
+unbounded camera errors. There is no cross-metric average or composite score.
+
+## Shared components and protocol
+
+OpenAI CLIP, LAION aesthetic heads and DINO checkpoint loading use canonical
+WorldFoundry components. Human and object appearance share one masked DINOv2
+extractor. Within a run, identical DINO checkpoint/device pairs share the model;
+preprocessing and batch-size state remain protocol-specific.
+WorldScore and MemoBench use the shared LAION MLP; MIND, WBench and
+WorldArena use the shared linear head. These heads are not interchangeable.
+WorldScore and 4DWorldBench share their 512-square IQA preprocessing and base
+implementation; that transform is not applied to WorldMemBench.
+
+WorldMemBench retains MUSIQ-SPAQ, its aspect-preserving OpenCV resize, stride-4
+quality sampling, score normalization, and equal-weight video aggregation.
+Revisit segmentation, frozen target association, masked crops, camera alignment,
+scene reconstruction and Dynamic Memory's event-gated VLM checklist retain
+the official protocols.
+
+## Evaluate generated outputs
+
+Use the official `model/category/trajectory/case/video.mp4` layout or an official
+JSONL manifest. Keep annotations, reference images, trajectory files and target
+poses with each case. The dataset is
+[`rescuerz/WorldMemBench`](https://huggingface.co/datasets/rescuerz/WorldMemBench).
+
+Install the selected metric dependencies into the prepared GPU environment;
+`worldfoundry/data/benchmarks/requirements/worldmembench.txt` lists the complete
+set without replacing its Torch/CUDA installation. Stage the official
+checkpoints explicitly. `--weights-dir` accepts the upstream metric asset
+layout; YAML checkpoint overrides can point to already staged shared assets.
+
+```bash
+python -m worldfoundry.evaluation.tasks.execution.runners.worldmembench.run_worldmembench_official_runner \
+ --run-official --input-path \
+ --weights-dir --config \
+ --output-dir --json
+```
+
+Repeat `--metric` to select metrics. `--gpus 0,1` shards videos across visible
+GPUs. `--expected-revisits 1|2|3` selects the official fixed revisit protocol;
+otherwise manifest values or official return detection apply.
+
+DA3-Streaming and ViPE preparation reuse existing `da3/results.npz`,
+`da3/scene_geometry.npz` and `vipe/results.npz` artifacts. To generate missing
+artifacts, configure `da3.source`, `da3.dinov2_source` and `vipe.source` for the
+official model runtimes, with optional `python` and `weights` overrides in each
+section. DA3-Streaming is not replaced with single-batch DA3, nor ViPE's default
+pipeline with WorldFoundry's reduced pose-only profile. Dynamic Memory requires
+the official VLM configuration; API keys stay in its configured environment
+variable, not CLI arguments.
+
+## Import official results
+
+```bash
+worldfoundry-eval zoo benchmark-run --benchmark-id worldmembench \
+ --mode official-validation --official-results-path \
+ --result-model-id --output-dir --json
+```
+
+Accepts an evaluation output root, its `metrics/` directory, or a metric's
+`summary.json`. Multiple model groups require explicit selection. Summary
+coverage, group breakdowns and adjacent `cases.jsonl` records are retained in
+`scorecard.json`, `raw_metric_table.jsonl` and `per_sample_scores.jsonl`.
+
+## Limitations
+
+GPU/checkpoint and complete-dataset replay remain pending; in-tree execution
+and weight-free tests do not establish full-suite or leaderboard validity.
+DA3-Streaming and full-profile ViPE inference require their configured model
+runtimes unless artifacts are already available. `model_failure` retains its
+official scored result; `not_applicable` is not zero-filled. `eval_failed` and
+`data_invalid` retain coverage and cause a nonzero exit, even with partial scores.
diff --git a/docs/fumadocs/content/docs/evaluation/benchmark-hub/worldmembench.zh.mdx b/docs/fumadocs/content/docs/evaluation/benchmark-hub/worldmembench.zh.mdx
new file mode 100644
index 000000000..101c5d406
--- /dev/null
+++ b/docs/fumadocs/content/docs/evaluation/benchmark-hub/worldmembench.zh.mdx
@@ -0,0 +1,89 @@
+---
+title: WorldMemBench
+description: 复用共享模型组件的仓内视觉记忆与相机控制评测。
+---
+
+
+
+## 范围
+
+[WorldMemBench](https://github.com/ZJU-REAL/WorldMemBench) 评测人物、物体、环境和
+动态记忆,以及相机控制和视频质量。WorldFoundry 的 12 项官方指标协议均在仓内执行,
+基于 MIT 许可的上游版本 `1b79840143a98e017891e893b5d3ef75b93078a3` 适配,
+不需要额外检出或安装 WorldMemBench。
+
+| 指标 ID | 官方分数字段 | 方向 |
+| --- | --- | --- |
+| `imaging_quality` | `score` | 越高越好 |
+| `aesthetic_quality` | `score` | 越高越好 |
+| `human_identity` | `score` | 越高越好 |
+| `human_appearance` | `score` | 越高越好 |
+| `object_geometry` | `score` | 越高越好 |
+| `object_appearance` | `score` | 越高越好 |
+| `environment_visible_memory` | `score` | 越高越好 |
+| `environment_depth_fidelity` | `depth_spearman` | 越高越好 |
+| `environment_scene_reconstruction` | `reconstruction_score` | 越高越好 |
+| `dynamic_memory` | `score` | 越高越好 |
+| `camera_rotation` | `camera_rotation_rpe_path` | 越低越好 |
+| `camera_translation` | `camera_translation_rpe` | 越低越好 |
+
+分数保留官方单位,包括负的深度相关系数和无上界的相机误差,不构造跨指标平均分或综合分。
+
+## 共享组件与协议
+
+OpenAI CLIP、LAION 美学头和 DINO 权重加载复用 WorldFoundry 的规范组件;人物和物体
+外观共用一份掩码 DINOv2 特征提取实现。同次评测中,相同权重路径与设备共享模型,
+预处理和批大小状态保持独立。WorldScore、MemoBench 使用共享 LAION MLP,
+MIND、WBench、WorldArena 使用共享线性头,两种头不能互换。WorldScore 与
+4DWorldBench 共用其 512 方形 IQA 预处理和基类,该变换不应用于 WorldMemBench。
+
+WorldMemBench 保留 MUSIQ-SPAQ、保持宽高比的 OpenCV 缩放、每 4 帧采样、分数归一化
+和视频等权聚合。重访分段、冻结目标关联、掩码裁剪、相机对齐、场景重建,以及动态记忆
+按事件门控的 VLM 问答均遵循官方协议。
+
+## 评测生成输出
+
+使用官方 `model/category/trajectory/case/video.mp4` 目录结构或官方 JSONL 清单。
+每个 case 保留标注、参考图、轨迹文件及目标相机位姿。数据集为
+[`rescuerz/WorldMemBench`](https://huggingface.co/datasets/rescuerz/WorldMemBench)。
+
+在已准备的 GPU 环境中安装所选指标的依赖;
+`worldfoundry/data/benchmarks/requirements/worldmembench.txt` 列出完整依赖,
+不替换环境中的 Torch/CUDA。显式准备官方权重;`--weights-dir` 接受上游指标资产布局,
+YAML 中的 checkpoint 覆盖项可以指向已经准备的共享资产。
+
+```bash
+python -m worldfoundry.evaluation.tasks.execution.runners.worldmembench.run_worldmembench_official_runner \
+ --run-official --input-path \
+ --weights-dir --config \
+ --output-dir --json
+```
+
+重复 `--metric` 可选择指标,`--gpus 0,1` 按视频划分到可见 GPU。
+`--expected-revisits 1|2|3` 选择官方固定重访协议;否则使用清单中的值或官方返回检测。
+
+DA3-Streaming 和 ViPE 预处理复用已有的 `da3/results.npz`、
+`da3/scene_geometry.npz` 和 `vipe/results.npz`。如需生成缺失资产,配置
+`da3.source`、`da3.dinov2_source` 和 `vipe.source` 指向官方模型运行时,
+各节还可指定 `python` 和 `weights`。不将 DA3-Streaming 替换为单批 DA3,
+也不将 ViPE 默认流水线替换为 WorldFoundry 的精简位姿配置。动态记忆需要官方 VLM
+配置;API 密钥保留在配置指定的环境变量中,不通过命令行传递。
+
+## 导入官方结果
+
+```bash
+worldfoundry-eval zoo benchmark-run --benchmark-id worldmembench \
+ --mode official-validation --official-results-path \
+ --result-model-id --output-dir --json
+```
+
+接受评测输出根目录、其中的 `metrics/` 目录或单指标的 `summary.json`。
+多模型结果必须显式选择模型。汇总覆盖率、分组明细和相邻的 `cases.jsonl` 记录
+保留在 `scorecard.json`、`raw_metric_table.jsonl` 和 `per_sample_scores.jsonl` 中。
+
+## 限制
+
+GPU/真实权重和完整数据集回放尚未完成;仓内执行与免权重测试不代表全套验证或排行榜
+有效性。没有预计算资产时,DA3-Streaming 和完整配置 ViPE 推理仍需要配置对应模型
+运行时。`model_failure` 保留官方赋分,`not_applicable` 不填零。
+`eval_failed` 和 `data_invalid` 保留覆盖率,并使运行器返回非零,即使已有部分分数。
diff --git a/docs/fumadocs/lib/benchmark-catalog-status.json b/docs/fumadocs/lib/benchmark-catalog-status.json
index 25ac9abd4..58e88ec5f 100644
--- a/docs/fumadocs/lib/benchmark-catalog-status.json
+++ b/docs/fumadocs/lib/benchmark-catalog-status.json
@@ -1810,6 +1810,27 @@
"summaryZh": "仓内 evaluator 按 task protocol 评估 artifact bundle",
"verificationStatus": "bounded_fixture_verified"
},
+ "worldmembench": {
+ "aliases": [],
+ "badges": ["integrated"],
+ "category": "World Models",
+ "categoryZh": "世界模型",
+ "domains": ["human-memory", "object-memory", "environment-memory", "dynamic-memory"],
+ "integrationStatus": "integrated",
+ "leaderboardValid": false,
+ "metrics": [
+ "imaging_quality", "aesthetic_quality", "human_identity", "human_appearance",
+ "object_geometry", "object_appearance", "environment_visible_memory",
+ "environment_depth_fidelity", "environment_scene_reconstruction", "dynamic_memory",
+ "camera_rotation", "camera_translation"
+ ],
+ "name": "WorldMemBench",
+ "requiresApi": false,
+ "runtimeKind": "in_tree_official_runtime",
+ "summary": "Runs twelve in-tree memory and camera metrics with shared model components",
+ "summaryZh": "复用共享模型组件,在仓内执行十二项记忆与相机指标",
+ "verificationStatus": "official_runtime_ready_real_data_validation_pending"
+ },
"worldmodelbench": {
"aliases": [],
"badges": [
diff --git a/docs/fumadocs/lib/benchmark-page-data.json b/docs/fumadocs/lib/benchmark-page-data.json
index 99cfaf864..f2eb5b521 100644
--- a/docs/fumadocs/lib/benchmark-page-data.json
+++ b/docs/fumadocs/lib/benchmark-page-data.json
@@ -1,8 +1,8 @@
{
"generatedFrom": "worldfoundry/data/benchmarks/catalog + tasks/external + recorded About facts + curated docs overrides",
- "benchmarkCount": 74,
+ "benchmarkCount": 75,
"leaderboardIngestedIds": [],
- "leaderboardPlaceholderCount": 74,
+ "leaderboardPlaceholderCount": 75,
"pages": {
"ai2thor": {
"id": "ai2thor",
@@ -10324,6 +10324,179 @@
"entries": []
}
},
+ "worldmembench": {
+ "id": "worldmembench",
+ "name": "WorldMemBench",
+ "projectPage": "",
+ "paperUrl": "",
+ "dimensions": [
+ {
+ "id": "imaging_quality",
+ "name": "Imaging Quality",
+ "nameZh": "Imaging Quality",
+ "kind": "axis",
+ "description": "Official MUSIQ-SPAQ frame scores, normalized upstream and averaged equally per video.",
+ "descriptionZh": ""
+ },
+ {
+ "id": "aesthetic_quality",
+ "name": "Aesthetic Quality",
+ "nameZh": "Aesthetic Quality",
+ "kind": "axis",
+ "description": "Official CLIP/LAION aesthetic frame scores, normalized upstream and averaged per video.",
+ "descriptionZh": ""
+ },
+ {
+ "id": "environment_scene_reconstruction",
+ "name": "Environment Scene Reconstruction",
+ "nameZh": "Environment Scene Reconstruction",
+ "kind": "axis",
+ "description": "Official scene reconstruction score at departure and revisit frames.",
+ "descriptionZh": ""
+ }
+ ],
+ "data": {
+ "summary": "",
+ "summaryZh": "",
+ "sizeLabel": "",
+ "sizeLabelZh": "",
+ "count": null,
+ "unit": "",
+ "sources": [
+ {
+ "kind": "github",
+ "id": "https://github.com/ZJU-REAL/WorldMemBench",
+ "license": ""
+ }
+ ],
+ "splits": [],
+ "modalities": [
+ "video",
+ "text"
+ ],
+ "license": "",
+ "notes": [],
+ "notesZh": [],
+ "notApplicable": false
+ },
+ "metrics": [
+ {
+ "id": "imaging_quality",
+ "name": "Imaging Quality",
+ "description": "Official MUSIQ-SPAQ frame scores, normalized upstream and averaged equally per video.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "imaging_quality"
+ },
+ {
+ "id": "aesthetic_quality",
+ "name": "Aesthetic Quality",
+ "description": "Official CLIP/LAION aesthetic frame scores, normalized upstream and averaged per video.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "aesthetic_quality"
+ },
+ {
+ "id": "human_identity",
+ "name": "Human Identity",
+ "description": "Human identity retention across departure and revisit segments.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "human_identity"
+ },
+ {
+ "id": "human_appearance",
+ "name": "Human Appearance",
+ "description": "Human appearance retention under the official revisit protocol.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "human_appearance"
+ },
+ {
+ "id": "object_geometry",
+ "name": "Object Geometry",
+ "description": "Object geometry retention under the official revisit protocol.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "object_geometry"
+ },
+ {
+ "id": "object_appearance",
+ "name": "Object Appearance",
+ "description": "Object appearance retention under the official revisit protocol.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "object_appearance"
+ },
+ {
+ "id": "environment_visible_memory",
+ "name": "Environment Visible Memory",
+ "description": "DINOv2 full-frame similarity at camera return frames.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "environment_visible_memory"
+ },
+ {
+ "id": "environment_depth_fidelity",
+ "name": "Environment Depth Fidelity",
+ "description": "Pixel-aligned DA3 depth-order Spearman correlation at return frames.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "environment_depth_fidelity"
+ },
+ {
+ "id": "environment_scene_reconstruction",
+ "name": "Environment Scene Reconstruction",
+ "description": "Official scene reconstruction score at departure and revisit frames.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "environment_scene_reconstruction"
+ },
+ {
+ "id": "dynamic_memory",
+ "name": "Dynamic Memory",
+ "description": "Event-gated eight-question VLM checklist score with per-category details retained.",
+ "descriptionZh": "",
+ "higherIsBetter": true,
+ "primary": false,
+ "leaderboardKey": "dynamic_memory"
+ },
+ {
+ "id": "camera_rotation",
+ "name": "Camera Rotation",
+ "description": "DA3 adjacent rotation RPE normalized by the reference rotation path.",
+ "descriptionZh": "",
+ "higherIsBetter": false,
+ "primary": false,
+ "leaderboardKey": "camera_rotation"
+ },
+ {
+ "id": "camera_translation",
+ "name": "Camera Translation",
+ "description": "DA3/ViPE path-normalized translation RPE following Sim(3) alignment.",
+ "descriptionZh": "",
+ "higherIsBetter": false,
+ "primary": false,
+ "leaderboardKey": "camera_translation"
+ }
+ ],
+ "leaderboard": {
+ "ingested": false,
+ "source": "",
+ "sourceZh": "",
+ "officialUrl": "",
+ "entries": []
+ }
+ },
"worldmodelbench": {
"id": "worldmodelbench",
"name": "WorldModelBench",
diff --git a/docs/fumadocs/lib/catalog-coverage-data.json b/docs/fumadocs/lib/catalog-coverage-data.json
index f9d95434b..bc4a9f4e3 100644
--- a/docs/fumadocs/lib/catalog-coverage-data.json
+++ b/docs/fumadocs/lib/catalog-coverage-data.json
@@ -1,7 +1,7 @@
{
"modelsTotal": 275,
"modelsListed": 265,
- "benchmarksTotal": 74,
+ "benchmarksTotal": 75,
"modelFamilies": [
{
"id": "video",
@@ -2214,7 +2214,7 @@
{
"id": "video",
"label": "Video & world",
- "count": 52,
+ "count": 53,
"entries": [
{
"id": "4dworldbench",
@@ -2545,6 +2545,13 @@
"worldbench"
]
},
+ {
+ "id": "worldmembench",
+ "name": "WorldMemBench",
+ "aliases": [
+ "worldmembench"
+ ]
+ },
{
"id": "worldmodelbench",
"name": "WorldModelBench",
diff --git a/tests/eval_core/test_wbench_official_runner.py b/tests/eval_core/test_wbench_official_runner.py
index 9906dcaed..0c765914e 100644
--- a/tests/eval_core/test_wbench_official_runner.py
+++ b/tests/eval_core/test_wbench_official_runner.py
@@ -1,3 +1,6 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
from __future__ import annotations
import json
@@ -7,6 +10,8 @@
from worldfoundry.evaluation.tasks.execution.runners.wbench import run_wbench_official_runner
+pytestmark = pytest.mark.unit
+
def test_wbench_official_runner_normalizes_report_json(tmp_path: Path) -> None:
report_path = tmp_path / "report.json"
@@ -42,11 +47,24 @@ def test_wbench_official_runner_normalizes_report_json(tmp_path: Path) -> None:
scorecard = json.loads((output_dir / "scorecard.json").read_text(encoding="utf-8"))
raw_rows = [
- json.loads(line)
- for line in (output_dir / "raw_metric_table.jsonl").read_text(encoding="utf-8").splitlines()
+ json.loads(line) for line in (output_dir / "raw_metric_table.jsonl").read_text(encoding="utf-8").splitlines()
]
assert exit_code == 0
assert scorecard["evaluation"]["kind"] == "wbench_result_normalizer"
- assert scorecard["metrics"]["leaderboard"]["quality_score"] == pytest.approx(0.7)
- assert scorecard["metrics"]["leaderboard"]["wbench_average"] == pytest.approx(0.64)
+ assert scorecard["metrics"]["leaderboard"] == pytest.approx(
+ {
+ "aesthetic_quality": 0.8,
+ "imaging_quality": 0.6,
+ "scene_adherence": 0.7,
+ "navigation_trajectory": 0.5,
+ "background_consistency": 0.9,
+ "visual_plausibility": 0.4,
+ }
+ )
+ assert scorecard["metrics"]["per_metric"]["quality_score"]["raw_score"] is None
+ assert scorecard["metrics"]["per_metric"]["wbench_average"]["raw_score"] is None
+ assert len(scorecard["evaluation"]["missing_component_metrics"]) == 16
+ assert not scorecard["evaluation"]["benchmark_comparable"]
+ assert not scorecard["leaderboard_valid"]
assert raw_rows[-1]["metric_id"] == "wbench_average"
+ assert raw_rows[-1]["raw_score"] is None
diff --git a/tests/eval_core/test_worldmembench_official_runner.py b/tests/eval_core/test_worldmembench_official_runner.py
new file mode 100644
index 000000000..2918b27bd
--- /dev/null
+++ b/tests/eval_core/test_worldmembench_official_runner.py
@@ -0,0 +1,403 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+import pytest
+import yaml
+
+from worldfoundry.evaluation.tasks.catalog.benchmark_catalog import formal_benchmark_ids
+from worldfoundry.evaluation.tasks.catalog.dispatch import official_runner_spec
+from worldfoundry.evaluation.tasks.catalog.registry import load_task_registry_from_paths
+from worldfoundry.evaluation.tasks.contracts.external import get_external_benchmark_contract
+from worldfoundry.evaluation.tasks.execution.orchestration.benchmark_runner import (
+ build_benchmark_runner_registry,
+ run_benchmark_execution,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench import run_worldmembench_official_runner as runner
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.worldmembench_metrics import (
+ METRIC_SPECS,
+ load_official_results,
+)
+
+pytestmark = pytest.mark.unit
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+MODULE = "worldfoundry.evaluation.tasks.execution.runners.worldmembench.run_worldmembench_official_runner"
+
+
+def _write_result(
+ root: Path,
+ metric: str = "human_identity",
+ *,
+ models: dict[str, dict[str, Any]] | None = None,
+ cases: list[dict[str, Any]] | None = None,
+) -> Path:
+ """Write the compact summary schema emitted by upstream ResultWriter."""
+ directory = root / "metrics" / metric
+ directory.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "num_cases": 2,
+ "results": models or {"model_a": {"num_cases": 2, "scores": {"score": 0.75}}},
+ "groups": [{"model_id": "model_a", "category": "human", "trajectory_id": "LRLR", "num_cases": 2}],
+ }
+ summary = directory / "summary.json"
+ summary.write_text(json.dumps(payload), encoding="utf-8")
+ if cases is not None:
+ (directory / "cases.jsonl").write_text("".join(json.dumps(case) + "\n" for case in cases), encoding="utf-8")
+ return summary
+
+
+def _run(tmp_path: Path, *flags: str) -> tuple[dict[str, Any], int]:
+ args = runner.build_parser().parse_args(["--output-dir", str(tmp_path / "out"), *flags])
+ return runner.run(args)
+
+
+def test_catalog_contract_task_runtime_and_dispatch_agree() -> None:
+ assert "worldmembench" in formal_benchmark_ids()
+ contract = get_external_benchmark_contract("worldmembench")
+ assert contract.metric_ids == tuple(METRIC_SPECS)
+ spec = official_runner_spec("worldmembench")
+ assert spec is not None
+ assert spec.module == MODULE
+ assert spec.supports_official_runtime and spec.artifact_official_run
+ assert spec.model_arg == "--model-id"
+ assert not spec.supports_fixture
+ registry = build_benchmark_runner_registry()
+ entry = registry.zoo.get("worldmembench")
+ assert entry.integration_status == "integrated"
+ assert not entry.official_benchmark_verified
+ assert not entry.leaderboard_valid
+ task_path = REPO_ROOT / "worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml"
+ task = load_task_registry_from_paths(task_path).get("worldmembench_standard", benchmark="worldmembench").task
+ assert task.metric_ids == contract.metric_ids
+ assert task.evaluation_protocol_names == ("external_official_runner",)
+ assert task.metadata["artifact_contract"]
+ for relative in [
+ "tasks/external/worldmembench.yaml",
+ "runtime_profiles/official/worldmembench.yaml",
+ ]:
+ payload = yaml.safe_load((REPO_ROOT / "worldfoundry/data/benchmarks" / relative).read_text())
+ if "metadata" in payload:
+ assert set(payload["metrics"]) == set(contract.metric_ids)
+ else:
+ assert payload["benchmark_ids"] == ["worldmembench"]
+ for item in payload["required_paths"]:
+ assert (REPO_ROOT / item["path"]).exists()
+
+
+@pytest.mark.parametrize("input_kind", ["root", "metrics", "summary"])
+def test_import_official_summary_layouts(tmp_path: Path, input_kind: str) -> None:
+ root = tmp_path / "official"
+ cases = [{"item_id": "case_001", "model_id": "model_a", "status": "ok", "scores": {"score": 0.75}}]
+ summary = _write_result(root, cases=cases)
+ source = {"root": root, "metrics": root / "metrics", "summary": summary}[input_kind]
+ model, extracted, imported_cases = load_official_results(source)
+ assert model == "model_a"
+ assert extracted["human_identity"]["raw_score"] == 0.75
+ assert extracted["human_identity"]["sample_count"] == 2
+ assert imported_cases == [{**cases[0], "metric_id": "human_identity"}]
+
+
+def test_models_are_explicitly_selected_without_pooling(tmp_path: Path) -> None:
+ _write_result(
+ tmp_path,
+ models={
+ "model_a": {"num_cases": 1, "scores": {"score": 0.2}},
+ "model_b": {"num_cases": 1, "scores": {"score": 0.9}},
+ },
+ cases=[
+ {"item_id": "a", "model_id": "model_a", "scores": {"score": 0.2}},
+ {"item_id": "b", "model_id": "model_b", "scores": {"score": 0.9}},
+ ],
+ )
+ with pytest.raises(ValueError, match="--model-id"):
+ load_official_results(tmp_path)
+ model, metrics, cases = load_official_results(tmp_path, model_id="model_b")
+ assert model == "model_b"
+ assert metrics["human_identity"]["raw_score"] == 0.9
+ assert [case["item_id"] for case in cases] == ["b"]
+ with pytest.raises(ValueError, match="not present"):
+ load_official_results(tmp_path, model_id="absent")
+
+
+def test_ungrouped_model_uses_official_all_group(tmp_path: Path) -> None:
+ _write_result(
+ tmp_path,
+ models={"all": {"num_cases": 1, "scores": {"score": 0.4}}},
+ cases=[{"item_id": "video.mp4", "model_id": None, "status": "ok"}],
+ )
+ model, _, cases = load_official_results(tmp_path)
+ assert model == "all"
+ assert len(cases) == 1
+
+
+def test_all_metrics_keep_official_units_without_a_composite(tmp_path: Path) -> None:
+ root = tmp_path / "official"
+ for metric, (key, _) in METRIC_SPECS.items():
+ value = -0.25 if metric == "environment_depth_fidelity" else 3.5 if metric.startswith("camera_") else 0.6
+ _write_result(root, metric, models={"model_a": {"num_cases": 2, "scores": {key: value}}})
+ scorecard, code = _run(tmp_path, "--official-results-path", str(root))
+ assert code == 0
+ metrics = scorecard["metrics"]["per_metric"]
+ assert set(metrics) == set(METRIC_SPECS)
+ assert scorecard["metrics"]["summary"]["available_metrics"] == 12
+ assert metrics["camera_rotation"]["raw_score"] == metrics["camera_rotation"]["normalized_score"] == 3.5
+ assert metrics["camera_translation"]["higher_is_better"] is False
+ assert metrics["environment_depth_fidelity"]["normalized_score"] == -0.25
+ assert not any("average" in key for key in scorecard["metrics"]["leaderboard"])
+ assert scorecard["normalizer_only"] is True
+ assert scorecard["integration_evidence"] is False
+ assert scorecard["official_benchmark_verified"] is False
+ assert scorecard["eligibility"]["leaderboard_valid"] is False
+
+
+@pytest.mark.parametrize(
+ "status,expected_code",
+ [
+ ("model_failure", 0),
+ ("not_applicable", 0),
+ ("eval_failed", 1),
+ ("data_invalid", 1),
+ ],
+)
+def test_case_statuses_and_coverage_are_preserved(tmp_path: Path, status: str, expected_code: int) -> None:
+ root = tmp_path / "official"
+ scored = 2 if status == "model_failure" else 1
+ _write_result(
+ root,
+ models={
+ "model_a": {
+ "num_cases": 2,
+ "num_scored": scored,
+ "status_counts": {"ok": 1, status: 1},
+ "scores": {"score": 0.25, "identity_score": 0.2},
+ }
+ },
+ cases=[{"item_id": "a", "model_id": "model_a", "status": status, "error": "metric detail"}],
+ )
+ card, code = _run(tmp_path, "--official-results-path", str(root))
+ assert code == expected_code
+ row = card["metrics"]["per_metric"]["human_identity"]
+ assert row["sample_count"] == scored
+ assert row["coverage"]["status_counts"][status] == 1
+ assert row["official_scores"]["identity_score"] == 0.2
+ assert row["official_groups"][0]["trajectory_id"] == "LRLR"
+ case = json.loads((tmp_path / "out/per_sample_scores.jsonl").read_text())
+ assert case["status"] == status
+ assert case["error"] == "metric detail"
+ if expected_code:
+ assert card["run"]["status"] == "failed"
+ assert card["evaluation"]["available"] is False
+ assert card["metrics"]["leaderboard"] == {}
+
+
+def test_all_failed_cases_keep_coverage_without_fabricated_scores(tmp_path: Path) -> None:
+ root = tmp_path / "official"
+ _write_result(
+ root,
+ models={
+ "model_a": {
+ "num_cases": 2,
+ "num_scored": 0,
+ "status_counts": {"eval_failed": 2},
+ }
+ },
+ )
+ card, code = _run(tmp_path, "--official-results-path", str(root))
+ assert code == 1
+ row = card["metrics"]["per_metric"]["human_identity"]
+ assert row["coverage"]["num_scored"] == 0
+ assert row["raw_score"] is None
+ assert not row["available"]
+ assert card["metrics"]["leaderboard"] == {}
+
+
+def test_requested_metrics_must_exist_for_the_selected_model(tmp_path: Path) -> None:
+ _write_result(tmp_path)
+ with pytest.raises(ValueError, match="Missing requested"):
+ load_official_results(tmp_path, metrics=("camera_rotation",))
+ with pytest.raises(ValueError, match="Unknown"):
+ load_official_results(tmp_path, metrics=("invented_metric",))
+ _write_result(tmp_path, "object_geometry", models={"model_b": {"num_cases": 1, "scores": {"score": 0.9}}})
+ with pytest.raises(ValueError, match="missing from"):
+ load_official_results(tmp_path, model_id="model_a")
+
+
+def test_missing_inputs_write_standard_failed_artifacts(tmp_path: Path) -> None:
+ card, code = _run(tmp_path)
+ assert code == 1
+ assert card["run"]["status"] == "failed"
+ for filename in ("scorecard.json", "raw_metric_table.jsonl", "per_sample_scores.jsonl"):
+ assert (tmp_path / "out" / filename).is_file()
+
+
+@pytest.mark.parametrize("upstream_code", [0, 1])
+def test_official_execution_uses_in_tree_runtime_and_respects_exit_status(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ upstream_code: int,
+) -> None:
+ manifest = tmp_path / "inputs.jsonl"
+ manifest.touch()
+ config = tmp_path / "config.yaml"
+ config.write_text("metrics: {}\n")
+ calls: list[list[str]] = []
+
+ def execute(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
+ calls.append(command)
+ assert "cwd" not in kwargs
+ assert kwargs["timeout"] == 7200
+ result_dir = Path(command[command.index("--output") + 1])
+ _write_result(result_dir)
+ return subprocess.CompletedProcess(command, upstream_code)
+
+ monkeypatch.setattr(runner.subprocess, "run", execute)
+ card, code = _run(
+ tmp_path,
+ "--run-official",
+ "--input-path",
+ str(manifest),
+ "--config",
+ str(config),
+ "--metrics",
+ "human_identity",
+ "--gpus",
+ "0,1",
+ "--expected-revisits",
+ "2",
+ )
+ assert code == upstream_code
+ command = calls[0]
+ assert command[:5] == [
+ sys.executable,
+ "-m",
+ "worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime",
+ "eval",
+ str(manifest),
+ ]
+ assert command[command.index("--metrics") + 1] == "human_identity"
+ assert "--gpus" in command and "--device" not in command
+ assert command[command.index("--config") + 1] == str(config)
+ assert card["validation"]["official_runtime_executed"] is True
+ assert card["integration_evidence"] is (upstream_code == 0)
+ assert card["evaluation"]["available"] is (upstream_code == 0)
+ assert card["validation"]["full_suite_complete"] is False
+ assert (tmp_path / "out/runtime_stdout.log").is_file()
+
+
+def test_official_execution_does_not_reuse_stale_outputs(tmp_path: Path) -> None:
+ _write_result(tmp_path / "out/official")
+ card, code = _run(tmp_path, "--run-official")
+ assert code == 1
+ assert "fresh output" in card["run"]["error"]
+
+
+def test_official_timeout_writes_failure_scorecard(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ video = tmp_path / "video.mp4"
+ video.touch()
+
+ def execute(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
+ raise subprocess.TimeoutExpired(command, kwargs["timeout"])
+
+ monkeypatch.setattr(runner.subprocess, "run", execute)
+ card, code = _run(tmp_path, "--run-official", "--input-path", str(video), "--timeout", "1")
+ assert code == 1
+ assert card["run"]["status"] == "failed"
+ assert "timed out" in card["run"]["error"]
+
+
+def test_standard_benchmark_contract_and_result_import(tmp_path: Path) -> None:
+ contract = run_benchmark_execution("worldmembench", output_dir=tmp_path / "contract", mode="contract")
+ assert contract.ok
+ assert contract.scorecard_path.is_file()
+ root = tmp_path / "official"
+ _write_result(root)
+ imported = run_benchmark_execution(
+ "worldmembench",
+ output_dir=tmp_path / "import",
+ mode="official-validation",
+ official_results_path=root,
+ )
+ assert imported.ok, imported.to_dict()
+ card = json.loads(imported.scorecard_path.read_text())
+ assert card["benchmark"]["benchmark_id"] == "worldmembench"
+ assert card["normalizer_only"] is True
+ assert card["metrics"]["per_metric"]["human_identity"]["raw_score"] == 0.75
+
+
+def test_standard_official_run_dispatches_generated_videos(tmp_path: Path) -> None:
+ interpreter = tmp_path / "metric-python"
+ payload = {"num_cases": 1, "results": {"all": {"num_cases": 1, "scores": {"score": 0.5}}}}
+ interpreter.write_text(
+ f"#!{sys.executable}\n"
+ "import json, pathlib, sys\n"
+ "assert sys.argv[1:4] == ['-m', 'worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime', 'eval']\n"
+ "output = pathlib.Path(sys.argv[sys.argv.index('--output') + 1])\n"
+ "directory = output / 'metrics' / 'imaging_quality'\n"
+ "directory.mkdir(parents=True)\n"
+ f"(directory / 'summary.json').write_text({json.dumps(payload)!r})\n",
+ encoding="utf-8",
+ )
+ interpreter.chmod(0o755)
+ videos = tmp_path / "videos"
+ videos.mkdir()
+ (videos / "case_001.mp4").touch()
+ result = run_benchmark_execution(
+ "worldmembench",
+ output_dir=tmp_path / "official-run",
+ mode="official-run",
+ generated_artifact_dir=videos,
+ env_overrides={"WORLDFOUNDRY_WORLDMEMBENCH_PYTHON": str(interpreter)},
+ )
+ assert result.ok, result.to_dict()
+ card = json.loads(result.scorecard_path.read_text())
+ assert card["validation"]["official_runtime_executed"] is True
+ assert card["integration_evidence"] is True
+ assert card["validation"]["full_suite_complete"] is False
+ assert card["metrics"]["leaderboard"] == {"imaging_quality": 0.5}
+
+
+@pytest.mark.parametrize("select_model", [False, True])
+def test_public_cli_imports_results(tmp_path: Path, select_model: bool) -> None:
+ root = tmp_path / "official"
+ _write_result(
+ root,
+ models={
+ "model_a": {"num_cases": 2, "scores": {"score": 0.75}},
+ **({"model_b": {"num_cases": 2, "scores": {"score": 0.2}}} if select_model else {}),
+ },
+ )
+ completed = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "worldfoundry.cli",
+ "zoo",
+ "benchmark-run",
+ "--benchmark-id",
+ "worldmembench",
+ "--mode",
+ "official-validation",
+ "--official-results-path",
+ str(root),
+ "--output-dir",
+ str(tmp_path / "cli"),
+ *(["--result-model-id", "model_a"] if select_model else []),
+ "--json",
+ ],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert completed.returncode == 0, completed.stderr + completed.stdout
+ card = json.loads((tmp_path / "cli/scorecard.json").read_text())
+ assert card["metrics"]["leaderboard"] == {"human_identity": 0.75}
+ assert card["eligibility"]["leaderboard_valid"] is False
diff --git a/tests/eval_core/test_worldmembench_runtime.py b/tests/eval_core/test_worldmembench_runtime.py
new file mode 100644
index 000000000..f491d342a
--- /dev/null
+++ b/tests/eval_core/test_worldmembench_runtime.py
@@ -0,0 +1,135 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Weight-free checks of the native WorldMemBench protocol and runner."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+import pytest
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.evaluate import evaluate
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics import registry
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.dynamic.checklist import CATEGORIES
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.dynamic.scoring import (
+ aggregate_scores,
+ score_answers,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.general_video_quality.aesthetic_quality import (
+ AestheticQualityMetric,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.general_video_quality.imaging_quality import (
+ ImagingQualityMetric,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.human.evaluator import (
+ MaskedDinoV2Features as HumanFeatures,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.object.evaluator import (
+ MaskedDinoV2Features as ObjectFeatures,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.registry import MetricRegistry
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import DecodedFrame, VideoInfo
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.worldmembench_metrics import (
+ METRIC_SPECS,
+ load_official_results,
+)
+
+pytestmark = pytest.mark.unit
+
+
+class _Reader:
+ def probe(self, path: str) -> VideoInfo:
+ return VideoInfo(9 if Path(path).stem == "long" else 5, 24.0, 2, 2)
+
+ def read_indices(self, path: str, indices: list[int]) -> list[DecodedFrame]:
+ value = 80 if Path(path).stem == "long" else 20
+ return [DecodedFrame(index, np.full((2, 2, 3), value, dtype=np.uint8)) for index in indices]
+
+
+class _Backend:
+ def score(self, frames: list[np.ndarray]) -> list[float]:
+ return [float(frame[0, 0, 0]) for frame in frames]
+
+
+def test_all_official_metrics_are_available_without_upstream_package() -> None:
+ assert set(registry.names()) == set(METRIC_SPECS)
+ completed = subprocess.run(
+ [sys.executable, "-m", "worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime", "list-metrics"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ assert set(completed.stdout.splitlines()) == set(METRIC_SPECS)
+
+
+def test_human_and_object_share_masked_feature_extraction() -> None:
+ assert HumanFeatures is ObjectFeatures
+ frame = np.arange(4 * 5 * 3, dtype=np.uint8).reshape(4, 5, 3)
+ mask = np.zeros((4, 5), dtype=bool)
+ mask[1:3, 1:4] = True
+ mask[1, 2] = False
+ crop = HumanFeatures._masked_crop(frame, mask)
+ assert crop is not None
+ expected = frame[1:3, 1:4].copy()
+ expected[0, 1] = 0
+ np.testing.assert_array_equal(crop, expected)
+ assert HumanFeatures._masked_crop(frame, np.zeros_like(mask)) is None
+
+
+@pytest.mark.parametrize("metric_type,normalizer", [(ImagingQualityMetric, 100.0), (AestheticQualityMetric, 10.0)])
+def test_quality_uses_stride_four_clipping_and_equal_video_weights(
+ tmp_path: Path, metric_type: Any, normalizer: float
+) -> None:
+ manifest = tmp_path / "inputs.jsonl"
+ rows = []
+ for name in ("long", "short"):
+ (tmp_path / f"{name}.mp4").touch()
+ rows.append({"item_id": name, "model_id": "model_a", "video": f"{name}.mp4"})
+ manifest.write_text("".join(json.dumps(row) + "\n" for row in rows))
+ metrics = MetricRegistry()
+ metric = metric_type(backend=_Backend(), reader=_Reader())
+ metrics.register(metric)
+ output, success = evaluate(
+ manifest,
+ metrics=[metric.name],
+ metric_registry=metrics,
+ device="cpu",
+ weights_dir=tmp_path / "assets",
+ output=tmp_path / "native",
+ )
+ assert success
+ model, extracted, cases = load_official_results(output)
+ assert model == "model_a"
+ expected = (min(1.0, 80.0 / normalizer) + min(1.0, 20.0 / normalizer)) / 2
+ assert extracted[metric.name]["raw_score"] == pytest.approx(expected)
+ assert [row["metadata"]["sampled_frame_indices"] for row in cases] == [[0, 4, 8], [0, 4]]
+ assert [row["num_units"] for row in cases] == [3, 2]
+
+
+@pytest.mark.parametrize("started,expected", [("yes", 1.0), ("no", 0.0)])
+def test_dynamic_memory_event_gate_applies_to_all_eight_questions(started: str, expected: float) -> None:
+ categories = [category for category in CATEGORIES for _ in range(8 // len(CATEGORIES))]
+ assert len(categories) == 8
+ checklist = SimpleNamespace(
+ questions=[
+ SimpleNamespace(
+ id=f"q{index}", expected_answer="yes", category=category, question="question", evidence_window="return"
+ )
+ for index, category in enumerate(categories)
+ ]
+ )
+ response = SimpleNamespace(
+ event_started=SimpleNamespace(answer=started),
+ answers=[SimpleNamespace(answer="yes", reason="visible", evidence_image_numbers=(1,)) for _ in range(8)],
+ )
+ units = score_answers(checklist, response, [4])
+ assert aggregate_scores(units)["score"] == expected
+ assert all(unit.metadata["ungated_score"] == 1.0 for unit in units)
+ assert all(unit.metadata["evidence_frame_indices"] == [4] for unit in units)
diff --git a/tests/evaluation/execution/test_shared_quality.py b/tests/evaluation/execution/test_shared_quality.py
new file mode 100644
index 000000000..7fef352a9
--- /dev/null
+++ b/tests/evaluation/execution/test_shared_quality.py
@@ -0,0 +1,269 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""CPU numerical contracts for shared quality components; no pretrained assets."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+import pytest
+from PIL import Image
+
+from worldfoundry.evaluation.tasks.metrics._shared.aesthetic import (
+ load_laion_aesthetic_linear_head,
+ load_laion_aesthetic_mlp_head,
+)
+from worldfoundry.evaluation.tasks.metrics._shared.pyiqa import ImageQualityMetric, PyIQAMetric
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture
+def torch_cpu() -> Any:
+ torch = pytest.importorskip("torch")
+ previous = torch.get_num_threads()
+ torch.set_num_threads(2)
+ yield torch
+ torch.set_num_threads(previous)
+
+
+@pytest.mark.parametrize("head", ["linear", "mlp"])
+def test_shared_laion_head_matches_reference_weights_and_predictions(tmp_path: Path, torch_cpu: Any, head: str) -> None:
+ torch, nn = torch_cpu, torch_cpu.nn
+ if head == "linear":
+ reference = nn.Linear(768, 1).eval()
+ state = reference.state_dict()
+ load = load_laion_aesthetic_linear_head
+ else:
+ reference = nn.Sequential(
+ nn.Linear(768, 1024),
+ nn.Dropout(0.2),
+ nn.Linear(1024, 128),
+ nn.Dropout(0.2),
+ nn.Linear(128, 64),
+ nn.Dropout(0.1),
+ nn.Linear(64, 16),
+ nn.Linear(16, 1),
+ ).eval()
+ state = {f"layers.{key}": value for key, value in reference.state_dict().items()}
+ load = load_laion_aesthetic_mlp_head
+ checkpoint = tmp_path / "head.pth"
+ torch.save(state, checkpoint)
+ actual = load(checkpoint)
+ features = torch.arange(3 * 768, dtype=torch.float32).reshape(3, 768) / 768
+ with torch.inference_mode():
+ torch.testing.assert_close(actual(features), reference(features), rtol=0, atol=0)
+ assert not actual.training
+
+
+def test_iqa_preprocessing_matches_official_resize_for_paths_and_pil(tmp_path: Path, torch_cpu: Any) -> None:
+ from torchvision import transforms
+
+ class Metric(ImageQualityMetric):
+ def _compute_scores(self, *args: Any) -> None:
+ pass
+
+ image = Image.fromarray(np.arange(19 * 31 * 3, dtype=np.uint8).reshape(19, 31, 3))
+ path = tmp_path / "image.png"
+ image.save(path)
+ metric = Metric(device="cpu")
+ transform = transforms.Compose([transforms.Resize((512, 512)), transforms.ToTensor()])
+ expected = torch_cpu.stack([transform(image), transform(image)])
+ torch_cpu.testing.assert_close(metric._process_image([str(path), image]), expected, rtol=0, atol=0)
+ for reference in (str(path), image):
+ rendered, references = metric._process_images([str(path), image], reference)
+ torch_cpu.testing.assert_close(rendered, expected, rtol=0, atol=0)
+ torch_cpu.testing.assert_close(references, expected, rtol=0, atol=0)
+
+
+@pytest.mark.parametrize("variant", ["musiq", "musiq-spaq", "musiq-paq2piq"])
+def test_shared_iqa_preserves_the_selected_checkpoint_variant(monkeypatch: pytest.MonkeyPatch, variant: str) -> None:
+ class Metric(PyIQAMetric):
+ def _compute_scores(self, *args: Any) -> None:
+ pass
+
+ calls: list[str] = []
+ model = SimpleNamespace(to=lambda device: device)
+ monkeypatch.setitem(sys.modules, "pyiqa", SimpleNamespace(create_metric=lambda name: calls.append(name) or model))
+ metric = Metric(variant, device="cpu")
+ assert calls == [variant]
+ assert metric._metric == "cpu"
+
+
+def test_dinov2_loader_preserves_explicit_cls_and_pooler_semantics(
+ monkeypatch: pytest.MonkeyPatch, torch_cpu: Any
+) -> None:
+ from worldfoundry.base_models.perception_core.general_perception.dino_embeddings import load_dinov2_feature_model
+
+ class Model(torch_cpu.nn.Module):
+ def forward(self, pixel_values: Any) -> Any:
+ return SimpleNamespace(last_hidden_state=pixel_values, pooler_output=pixel_values[:, 1])
+
+ calls: list[tuple[str, bool]] = []
+
+ def load(source: str, *, local_files_only: bool) -> Any:
+ calls.append((source, local_files_only))
+ return Model()
+
+ monkeypatch.setitem(sys.modules, "transformers", SimpleNamespace(AutoModel=SimpleNamespace(from_pretrained=load)))
+ features = torch_cpu.arange(12, dtype=torch_cpu.float32).reshape(2, 3, 2)
+ cls_model = load_dinov2_feature_model("local-dinov2-large", device="cpu")
+ pooler_model = load_dinov2_feature_model("local-dinov2-base", use_pooler_output=True)
+ torch_cpu.testing.assert_close(cls_model(features), features[:, 0], rtol=0, atol=0)
+ torch_cpu.testing.assert_close(pooler_model(features), features[:, 1], rtol=0, atol=0)
+ assert calls == [("local-dinov2-large", True), ("local-dinov2-base", True)]
+ assert not cls_model.training
+
+
+def test_masked_dino_batch_and_single_paths_keep_the_official_transform(torch_cpu: Any) -> None:
+ from torchvision import transforms
+
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.features import (
+ MaskedDinoV2Features,
+ )
+
+ class Model(torch_cpu.nn.Module):
+ def forward(self, tensor: Any) -> Any:
+ assert tensor.shape[-2:] == (14, 14)
+ return tensor.mean(dim=(-2, -1))
+
+ model = Model().eval()
+ extractor = MaskedDinoV2Features(model=model, device="cpu", input_size=14, batch_size=2)
+ frame = np.arange(7 * 9 * 3, dtype=np.uint8).reshape(7, 9, 3)
+ mask = np.zeros((7, 9), dtype=bool)
+ mask[1:6, 2:8] = True
+ mask[2, 3] = False
+ crop = frame[1:6, 2:8].copy()
+ crop[1, 1] = 0
+ transform = transforms.Compose(
+ [
+ transforms.ToTensor(),
+ transforms.Resize((14, 14), antialias=True),
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
+ ]
+ )
+ reference = model(transform(crop).unsqueeze(0))[0].numpy()
+ reference = reference / float(np.linalg.norm(reference))
+ result = extractor.embed_batch([(frame, np.zeros_like(mask)), (frame, mask)])
+ assert result[0] is None
+ assert result[1] is not None
+ single = extractor.embed(frame, mask)
+ assert single is not None
+ np.testing.assert_array_equal(result[1], reference)
+ np.testing.assert_array_equal(single, reference)
+
+
+def test_memory_protocols_share_backbone_but_not_batch_state(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, torch_cpu: Any
+) -> None:
+ from worldfoundry.base_models.perception_core.general_perception import dino_embeddings
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.features import get_dino_model
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.human.evaluator import (
+ _dino_extractor,
+ )
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.object.evaluator import (
+ _feature_extractor,
+ )
+
+ checkpoint = tmp_path / "dinov2-large"
+ calls: list[Path] = []
+
+ def load(path: Path, *, device: str) -> Any:
+ assert device == "cpu"
+ calls.append(path)
+ return torch_cpu.nn.Identity()
+
+ monkeypatch.setattr(dino_embeddings, "load_dinov2_feature_model", load)
+ context = EvaluationContext(
+ device="cpu",
+ cache_dir=tmp_path / "assets",
+ config={
+ "metrics": {
+ "human_appearance": {"dino_checkpoint": str(checkpoint)},
+ "object_appearance": {"dino_checkpoint": str(checkpoint)},
+ }
+ },
+ )
+ try:
+ human = _dino_extractor(context)
+ obj = _feature_extractor(context)
+ assert human is not obj
+ assert human.model is obj.model is get_dino_model(context, checkpoint)
+ assert human.effective_batch_size > obj.effective_batch_size == 1
+ assert len(calls) == 1
+ assert get_dino_model(context, tmp_path / "other-checkpoint") is not human.model
+ assert len(calls) == 2
+ finally:
+ context.close()
+
+
+def test_wbench_binds_the_official_koniq_asset_without_global_cache_changes(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, torch_cpu: Any
+) -> None:
+ from worldfoundry.base_models.capabilities import BASE_MODEL_CAPABILITIES
+ from worldfoundry.evaluation.tasks.execution.runners.wbench.runtime.wbench.src.metrics.video_quality import (
+ imaging_quality,
+ )
+
+ checkpoint = tmp_path / "musiq_koniq_ckpt-e95806b9.pth"
+ asset_ids: list[str] = []
+ calls: list[tuple[str, dict[str, Any]]] = []
+ model = object()
+
+ def create(name: str, **kwargs: Any) -> Any:
+ calls.append((name, kwargs))
+ return model
+
+ monkeypatch.setitem(sys.modules, "pyiqa", SimpleNamespace(create_metric=create))
+ monkeypatch.setattr(imaging_quality, "wbench_asset_path", lambda name: asset_ids.append(name) or checkpoint)
+ metric = imaging_quality.ImagingQualityMetric(device="cpu")
+ assert metric.model is model
+ assert calls == [("musiq", {"device": "cpu", "pretrained_model_path": str(checkpoint)})]
+ assert asset_ids == ["wbench_musiq_koniq_checkpoint"]
+ asset = next(
+ asset for asset in BASE_MODEL_CAPABILITIES["wbench_quality_metric_assets"].assets if asset.id == asset_ids[0]
+ )
+ assert str(asset.local_path).endswith(checkpoint.name)
+
+
+@pytest.mark.parametrize("height,width", [(180, 320), (720, 1280)])
+def test_worldmembench_spaq_preserves_opencv_aspect_resize(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, torch_cpu: Any, height: int, width: int
+) -> None:
+ cv2 = pytest.importorskip("cv2")
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.general_video_quality.musiq import (
+ MusiqSpaqBackend,
+ )
+
+ captured: list[Any] = []
+
+ class Model(torch_cpu.nn.Module):
+ def forward(self, tensor: Any) -> Any:
+ captured.append(tensor.clone())
+ return torch_cpu.full((len(tensor),), 50.0)
+
+ checkpoint = tmp_path / "musiq_spaq_ckpt-358bb6af.pth"
+ checkpoint.touch()
+ model = Model()
+
+ def load(*, pretrained_model_path: str) -> Any:
+ assert pretrained_model_path == str(checkpoint)
+ return model
+
+ monkeypatch.setitem(sys.modules, "pyiqa.archs.musiq_arch", SimpleNamespace(MUSIQ=load))
+ backend = MusiqSpaqBackend(checkpoint=checkpoint, device="cpu")
+ assert not backend.model.training
+ frame = np.arange(height * width * 3, dtype=np.uint8).reshape(height, width, 3)
+ expected = frame
+ if max(height, width) > 512:
+ scale = 512 / max(height, width)
+ expected = cv2.resize(frame, (int(width * scale), int(height * scale)), interpolation=cv2.INTER_LINEAR)
+ tensor = torch_cpu.from_numpy(expected.copy()).permute(2, 0, 1).float().unsqueeze(0) / 255
+ assert backend.score([frame]) == [50.0]
+ torch_cpu.testing.assert_close(captured[0], tensor, rtol=0, atol=0)
diff --git a/tests/evaluation/execution/test_worldmembench_camera.py b/tests/evaluation/execution/test_worldmembench_camera.py
new file mode 100644
index 000000000..1f7a3127f
--- /dev/null
+++ b/tests/evaluation/execution/test_worldmembench_camera.py
@@ -0,0 +1,73 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Weight-free camera replay with OpenCV and staged synthetic pose artifacts."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench import run_worldmembench_official_runner as runner
+
+pytestmark = pytest.mark.unit
+
+
+def test_native_camera_runner_scores_staged_poses_without_model_runtimes(tmp_path: Path) -> None:
+ cv2 = pytest.importorskip("cv2")
+ video = tmp_path / "video.mp4"
+ writer = cv2.VideoWriter(str(video), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (24, 16))
+ assert writer.isOpened()
+ for _ in range(9):
+ writer.write(np.zeros((16, 24, 3), dtype=np.uint8))
+ writer.release()
+ poses = np.repeat(np.eye(4)[None], 9, axis=0)
+ angles = np.linspace(0, 0.5, 9)
+ poses[:, 0, 0] = poses[:, 1, 1] = np.cos(angles)
+ poses[:, 1, 0] = np.sin(angles)
+ poses[:, 0, 1] = -np.sin(angles)
+ poses[:, :3, 3] = np.column_stack((angles, angles**2, np.sin(angles)))
+ np.savez(tmp_path / "target.npz", c2w=poses)
+ np.savez(tmp_path / "da3.npz", extrinsics=np.linalg.inv(poses), frame_indices=np.arange(9))
+ np.savez(tmp_path / "vipe.npz", c2w=poses, frame_indices=np.arange(9))
+ manifest = tmp_path / "manifest.jsonl"
+ manifest.write_text(
+ json.dumps(
+ {
+ "item_id": "case",
+ "model_id": "model_a",
+ "video": "video.mp4",
+ "annotations": {"camera_motion_type": "mixed"},
+ "artifacts": {"target_poses": "target.npz", "da3": "da3.npz", "vipe": "vipe.npz"},
+ }
+ )
+ + "\n"
+ )
+ args = runner.build_parser().parse_args(
+ [
+ "--run-official",
+ "--input-path",
+ str(manifest),
+ "--device",
+ "cpu",
+ "--metric",
+ "camera_rotation,camera_translation",
+ "--python",
+ sys.executable,
+ "--weights-dir",
+ str(tmp_path / "assets"),
+ "--output-dir",
+ str(tmp_path / "out"),
+ ]
+ )
+ scorecard, code = runner.run(args)
+ assert code == 0, (tmp_path / "out/runtime_stderr.log").read_text()
+ for name in ("camera_rotation", "camera_translation"):
+ metric = scorecard["metrics"]["per_metric"][name]
+ assert metric["raw_score"] == pytest.approx(0.0, abs=1e-6)
+ assert not metric["higher_is_better"]
+ assert len((tmp_path / "out/per_sample_scores.jsonl").read_text().splitlines()) == 2
diff --git a/worldfoundry/base_models/capabilities.py b/worldfoundry/base_models/capabilities.py
index efaa25c1b..2c56b88e0 100644
--- a/worldfoundry/base_models/capabilities.py
+++ b/worldfoundry/base_models/capabilities.py
@@ -15,7 +15,9 @@
from worldfoundry.core.io.paths import worldfoundry_path_tokens
-REPO_ROOT = Path(os.environ.get("WORLDFOUNDRY_REPO_ROOT") or worldfoundry_path_tokens()["WORLDFOUNDRY_REPO_ROOT"]).expanduser()
+REPO_ROOT = Path(
+ os.environ.get("WORLDFOUNDRY_REPO_ROOT") or worldfoundry_path_tokens()["WORLDFOUNDRY_REPO_ROOT"]
+).expanduser()
def _path_tokens(env: Mapping[str, str] | None = None) -> dict[str, str]:
@@ -44,8 +46,7 @@ def _path_tokens(env: Mapping[str, str] | None = None) -> dict[str, str]:
).expanduser()
)
worldscore_asset_dir = Path(
- environ.get("WORLDFOUNDRY_WORLDSCORE_ASSET_DIR")
- or cache_root / "assets" / "worldscore_official"
+ environ.get("WORLDFOUNDRY_WORLDSCORE_ASSET_DIR") or cache_root / "assets" / "worldscore_official"
).expanduser()
return {
"WORLDFOUNDRY_REPO_ROOT": repo_root,
@@ -60,8 +61,10 @@ def _path_tokens(env: Mapping[str, str] | None = None) -> dict[str, str]:
"WORLDFOUNDRY_MODEL_DIR": core_tokens["WORLDFOUNDRY_MODEL_DIR"],
"WORLDFOUNDRY_WORLDSCORE_ASSET_DIR": str(worldscore_asset_dir),
"WORLDFOUNDRY_WORLDSCORE_DATASET_DIR": str(
- Path(environ.get("WORLDFOUNDRY_WORLDSCORE_DATASET_DIR") or worldscore_asset_dir / "data" / "WorldScore-Dataset")
- .expanduser()
+ Path(
+ environ.get("WORLDFOUNDRY_WORLDSCORE_DATASET_DIR")
+ or worldscore_asset_dir / "data" / "WorldScore-Dataset"
+ ).expanduser()
),
"WORLDFOUNDRY_WORLDSCORE_ASSET_CHECKPOINT_DIR": str(
Path(
@@ -557,11 +560,7 @@ def pip_package_by_import(self) -> dict[str, str]:
return {}
if self.install_packages and len(self.install_packages) == len(self.package_imports):
pairs = zip(self.package_imports, self.install_packages)
- return {
- import_name: package
- for import_name, package in pairs
- if import_name not in non_pip and package
- }
+ return {import_name: package for import_name, package in pairs if import_name not in non_pip and package}
pip_imports = [name for name in self.package_imports if name not in non_pip]
install_packages = self.install_packages or tuple(pip_imports)
return {
@@ -928,7 +927,17 @@ def _perception_data_capability(
family="depth",
canonical_owner="worldfoundry.base_models.three_dimensions.depth.depth_anything.depth_anything_v3",
canonical_path="worldfoundry/base_models/three_dimensions/depth/depth_anything/depth_anything_v3",
- package_imports=("torch", "torchvision", "cv2", "numpy", "PIL", "omegaconf", "einops", "huggingface_hub", "safetensors"),
+ package_imports=(
+ "torch",
+ "torchvision",
+ "cv2",
+ "numpy",
+ "PIL",
+ "omegaconf",
+ "einops",
+ "huggingface_hub",
+ "safetensors",
+ ),
install_packages=(
"torch",
"torchvision",
@@ -1300,8 +1309,24 @@ def _perception_data_capability(
family="depth",
canonical_owner="worldfoundry.base_models.three_dimensions.depth.unidepth",
canonical_path="worldfoundry/base_models/three_dimensions/depth/unidepth",
- package_imports=("torch", "torchvision", "numpy", "einops", "huggingface_hub", "safetensors", "xformers.components.attention"),
- install_packages=("torch", "torchvision", "numpy", "einops", "huggingface-hub", "safetensors", "xformers==0.0.28.post3"),
+ package_imports=(
+ "torch",
+ "torchvision",
+ "numpy",
+ "einops",
+ "huggingface_hub",
+ "safetensors",
+ "xformers.components.attention",
+ ),
+ install_packages=(
+ "torch",
+ "torchvision",
+ "numpy",
+ "einops",
+ "huggingface-hub",
+ "safetensors",
+ "xformers==0.0.28.post3",
+ ),
asset_env=("WORLDFOUNDRY_UNIDEPTH_V2_VITL14_MODEL_DIR",),
assets=(
BaseModelAsset(
@@ -1548,9 +1573,7 @@ def _perception_data_capability(
role="checkpoint",
env=("WORLDFOUNDRY_DEAOT_R50_CKPT",),
local_path="${WORLDFOUNDRY_CKPT_DIR}/R50_DeAOTL_PRE_YTB_DAV.pth",
- alternate_paths=(
- "${WORLDFOUNDRY_WORKSPACE_ROOT}/ckpt/R50_DeAOTL_PRE_YTB_DAV.pth",
- ),
+ alternate_paths=("${WORLDFOUNDRY_WORKSPACE_ROOT}/ckpt/R50_DeAOTL_PRE_YTB_DAV.pth",),
min_size_bytes=150_000_000,
note="DeAOT-L R50 checkpoint used by Segment-and-Track-Anything object attribute metrics.",
),
@@ -1618,7 +1641,16 @@ def _perception_data_capability(
canonical_owner="worldfoundry.base_models.perception_core.frame_interpolation.vfimamba",
canonical_path="worldfoundry/base_models/perception_core/frame_interpolation/vfimamba",
package_imports=("torch", "cv2", "numpy", "timm", "einops", "mamba_ssm", "huggingface_hub", "transformers"),
- install_packages=("torch", "opencv-python", "numpy", "timm", "einops", "mamba-ssm", "huggingface-hub", "transformers"),
+ install_packages=(
+ "torch",
+ "opencv-python",
+ "numpy",
+ "timm",
+ "einops",
+ "mamba-ssm",
+ "huggingface-hub",
+ "transformers",
+ ),
asset_env=("WORLDFOUNDRY_VFIMAMBA_CKPT", "WORLDFOUNDRY_VFIMAMBA_S_CKPT"),
assets=(
BaseModelAsset(
@@ -1742,17 +1774,17 @@ def _perception_data_capability(
download_command_template=(
"RAFT_CKPT=${WORLDFOUNDRY_RAFT_THINGS_CKPT:-${WORLDFOUNDRY_WORLDSCORE_ASSET_CHECKPOINT_DIR}/models/raft-things.pth}; "
"RAFT_ZIP=${WORLDFOUNDRY_RAFT_MODELS_ZIP:-${WORLDFOUNDRY_WORLDSCORE_ASSET_CHECKPOINT_DIR}/models.zip}; "
- "mkdir -p \"$(dirname \"$RAFT_CKPT\")\" \"$(dirname \"$RAFT_ZIP\")\"; "
- "if [ ! -f \"$RAFT_CKPT\" ]; then "
- "curl -L --fail --retry 5 --retry-delay 2 https://dl.dropboxusercontent.com/s/4j4z58wuv8o0mfz/models.zip -o \"$RAFT_ZIP\" "
+ 'mkdir -p "$(dirname "$RAFT_CKPT")" "$(dirname "$RAFT_ZIP")"; '
+ 'if [ ! -f "$RAFT_CKPT" ]; then '
+ 'curl -L --fail --retry 5 --retry-delay 2 https://dl.dropboxusercontent.com/s/4j4z58wuv8o0mfz/models.zip -o "$RAFT_ZIP" '
"&& python -c 'import pathlib, sys, zipfile; "
- "member=\"models/raft-things.pth\"; "
+ 'member="models/raft-things.pth"; '
"archive=zipfile.ZipFile(sys.argv[1]); "
"info=archive.getinfo(member); "
"target=pathlib.Path(sys.argv[2]); "
"target.parent.mkdir(parents=True, exist_ok=True); "
"target.write_bytes(archive.read(info)); "
- "print(target)' \"$RAFT_ZIP\" \"$RAFT_CKPT\"; "
+ 'print(target)\' "$RAFT_ZIP" "$RAFT_CKPT"; '
"fi"
),
min_size_bytes=20_000_000,
@@ -1803,7 +1835,7 @@ def _perception_data_capability(
min_file_count=10,
download_command_template=(
"COCO_ROOT=${WORLDFOUNDRY_COCO2017_ROOT:-${WORLDFOUNDRY_DATA_DIR}/perception/coco2017}; "
- "mkdir -p \"$COCO_ROOT\" && cd \"$COCO_ROOT\"; "
+ 'mkdir -p "$COCO_ROOT" && cd "$COCO_ROOT"; '
"if [ ! -d val2017 ]; then "
"curl -L --fail --retry 5 --retry-delay 2 http://images.cocodataset.org/zips/val2017.zip -o val2017.zip "
"&& python -m zipfile -e val2017.zip .; "
@@ -1828,12 +1860,12 @@ def _perception_data_capability(
min_file_count=20,
download_command_template=(
"DAVIS_ROOT=${WORLDFOUNDRY_DAVIS2017_ROOT:-${WORLDFOUNDRY_DATA_DIR}/perception/davis2017/DAVIS}; "
- "DAVIS_PARENT=$(dirname \"$DAVIS_ROOT\"); "
- "mkdir -p \"$DAVIS_PARENT\"; "
- "if [ ! -d \"$DAVIS_ROOT/JPEGImages/480p\" ]; then "
+ 'DAVIS_PARENT=$(dirname "$DAVIS_ROOT"); '
+ 'mkdir -p "$DAVIS_PARENT"; '
+ 'if [ ! -d "$DAVIS_ROOT/JPEGImages/480p" ]; then '
"curl -L --fail --retry 5 --retry-delay 2 https://data.vision.ee.ethz.ch/csergi/share/davis/DAVIS-2017-trainval-480p.zip "
- "-o \"$DAVIS_PARENT/DAVIS-2017-trainval-480p.zip\" "
- "&& python -m zipfile -e \"$DAVIS_PARENT/DAVIS-2017-trainval-480p.zip\" \"$DAVIS_PARENT\"; "
+ '-o "$DAVIS_PARENT/DAVIS-2017-trainval-480p.zip" '
+ '&& python -m zipfile -e "$DAVIS_PARENT/DAVIS-2017-trainval-480p.zip" "$DAVIS_PARENT"; '
"fi"
),
note="DAVIS 2017 trainval 480p bundle for video object segmentation and tracking metric validation.",
@@ -1851,12 +1883,12 @@ def _perception_data_capability(
min_file_count=10,
download_command_template=(
"TUM_ROOT=${WORLDFOUNDRY_TUM_RGBD_SLAM_SAMPLE_ROOT:-${WORLDFOUNDRY_DATA_DIR}/perception/tum_rgbd/rgbd_dataset_freiburg1_desk}; "
- "TUM_PARENT=$(dirname \"$TUM_ROOT\"); "
- "mkdir -p \"$TUM_PARENT\"; "
- "if [ ! -f \"$TUM_ROOT/rgb.txt\" ]; then "
+ 'TUM_PARENT=$(dirname "$TUM_ROOT"); '
+ 'mkdir -p "$TUM_PARENT"; '
+ 'if [ ! -f "$TUM_ROOT/rgb.txt" ]; then '
"curl -L --fail --retry 5 --retry-delay 2 https://vision.in.tum.de/rgbd/dataset/freiburg1/rgbd_dataset_freiburg1_desk.tgz "
- "-o \"$TUM_PARENT/rgbd_dataset_freiburg1_desk.tgz\" "
- "&& tar -xzf \"$TUM_PARENT/rgbd_dataset_freiburg1_desk.tgz\" -C \"$TUM_PARENT\"; "
+ '-o "$TUM_PARENT/rgbd_dataset_freiburg1_desk.tgz" '
+ '&& tar -xzf "$TUM_PARENT/rgbd_dataset_freiburg1_desk.tgz" -C "$TUM_PARENT"; '
"fi"
),
note="Small official TUM RGB-D Freiburg1 desk sequence for SLAM/depth validation and trajectory sanity checks.",
@@ -1905,9 +1937,7 @@ def _perception_data_capability(
role="metric_checkpoint_bundle",
env=("WORLDFOUNDRY_WORLDSCORE_ASSET_CHECKPOINT_DIR",),
local_path="${WORLDFOUNDRY_WORLDSCORE_ASSET_CHECKPOINT_DIR}",
- alternate_paths=(
- "${WORLDFOUNDRY_WORLDSCORE_ASSET_DIR}/metrics/checkpoints",
- ),
+ alternate_paths=("${WORLDFOUNDRY_WORLDSCORE_ASSET_DIR}/metrics/checkpoints",),
min_file_count=8,
note="WorldScore metric checkpoint bundle containing DROID, SAM, SAM2, RAFT, SEA-RAFT, and quality checkpoints.",
),
@@ -1981,9 +2011,7 @@ def _perception_data_capability(
role="benchmark_dataset",
env=("WORLDFOUNDRY_VIDEOSCORE_BENCH_DATASET_ROOT",),
local_path="${WORLDFOUNDRY_HFD_DATASET_ROOT}/TIGER-Lab__VideoScore-Bench",
- alternate_paths=(
- "${WORLDFOUNDRY_WORKSPACE_ROOT}/data/hfd_datasets/TIGER-Lab__VideoScore-Bench",
- ),
+ alternate_paths=("${WORLDFOUNDRY_WORKSPACE_ROOT}/data/hfd_datasets/TIGER-Lab__VideoScore-Bench",),
hf_repo_id="TIGER-Lab/VideoScore-Bench",
hf_repo_type="dataset",
min_file_count=4,
@@ -2269,20 +2297,19 @@ def _perception_data_capability(
note="LAION aesthetic predictor linear head used by WBench aesthetic quality.",
),
BaseModelAsset(
- id="wbench_musiq_spaq_checkpoint",
+ id="wbench_musiq_koniq_checkpoint",
kind="file",
role="checkpoint",
- env=("WORLDFOUNDRY_WBENCH_MUSIQ_SPAQ_CKPT",),
- local_path="${WORLDFOUNDRY_CACHE_DIR}/models/wbench/pyiqa_model/musiq_spaq_ckpt-358bb6af.pth",
+ env=("WORLDFOUNDRY_WBENCH_MUSIQ_KONIQ_CKPT",),
+ local_path="${WORLDFOUNDRY_CACHE_DIR}/models/wbench/pyiqa_model/musiq_koniq_ckpt-e95806b9.pth",
alternate_paths=(
- "${WORLDFOUNDRY_CKPT_DIR}/WBench/pyiqa_model/musiq_spaq_ckpt-358bb6af.pth",
- "${WORLDFOUNDRY_CKPT_DIR}/WBench/weights/pyiqa/musiq_spaq_ckpt-358bb6af.pth",
- "${WORLDFOUNDRY_CKPT_DIR}/WBench/weights/pyiqa_model/musiq_spaq_ckpt-358bb6af.pth",
- "${WORLDFOUNDRY_CKPT_DIR}/VBench/pyiqa_model/musiq_spaq_ckpt-358bb6af.pth",
+ "${WORLDFOUNDRY_CKPT_DIR}/WBench/pyiqa_model/musiq_koniq_ckpt-e95806b9.pth",
+ "${WORLDFOUNDRY_CKPT_DIR}/WBench/weights/pyiqa/musiq_koniq_ckpt-e95806b9.pth",
+ "${WORLDFOUNDRY_CKPT_DIR}/WBench/weights/pyiqa_model/musiq_koniq_ckpt-e95806b9.pth",
),
- download_url="https://github.com/chaofengc/IQA-PyTorch/releases/download/v0.1-weights/musiq_spaq_ckpt-358bb6af.pth",
+ download_url="https://github.com/chaofengc/IQA-PyTorch/releases/download/v0.1-weights/musiq_koniq_ckpt-e95806b9.pth",
min_size_bytes=100_000_000,
- note="MUSIQ-SPAQ checkpoint used by WBench imaging quality.",
+ note="MUSIQ-KonIQ checkpoint selected by the official WBench pyiqa.create_metric('musiq') call.",
),
BaseModelAsset(
id="wbench_clip_vit_b16_model_dir",
@@ -2605,9 +2632,7 @@ def _perception_data_capability(
min_file_count=8,
required_files=("constants.py", "models/model.py", "models/vqascore_models/__init__.py"),
required_paths=("models",),
- note=(
- "In-tree VQAScore model backend shared by evaluation scorers."
- ),
+ note=("In-tree VQAScore model backend shared by evaluation scorers."),
),
),
gpu_required=False,
@@ -2642,9 +2667,7 @@ def _perception_data_capability(
role="metric_tokenizer_dir",
env=("WORLDFOUNDRY_T2V_METRICS_FLAN_T5_XXL_DIR",),
local_path="${WORLDFOUNDRY_HFD_ROOT}/google--flan-t5-xxl",
- alternate_paths=(
- "${WORLDFOUNDRY_WORKSPACE_ROOT}/ckpt/hfd/google--flan-t5-xxl",
- ),
+ alternate_paths=("${WORLDFOUNDRY_WORKSPACE_ROOT}/ckpt/hfd/google--flan-t5-xxl",),
hf_repo_id="google/flan-t5-xxl",
min_file_count=2,
required_files=("config.json", "tokenizer_config.json"),
@@ -2667,9 +2690,7 @@ def _perception_data_capability(
role="metric_model_dir",
env=("WORLDFOUNDRY_T2V_METRICS_BLIP2_ITM_DIR",),
local_path="${WORLDFOUNDRY_HFD_ROOT}/Salesforce--blip2-opt-2.7b",
- alternate_paths=(
- "${WORLDFOUNDRY_WORKSPACE_ROOT}/ckpt/hfd/Salesforce--blip2-opt-2.7b",
- ),
+ alternate_paths=("${WORLDFOUNDRY_WORKSPACE_ROOT}/ckpt/hfd/Salesforce--blip2-opt-2.7b",),
hf_repo_id="Salesforce/blip2-opt-2.7b",
min_file_count=2,
required_files=("config.json",),
@@ -2749,9 +2770,7 @@ def _perception_data_capability(
role="metric_model_file",
env=("WORLDFOUNDRY_JEDI_VITH16_CHECKPOINT",),
local_path="${WORLDFOUNDRY_JEDI_MODEL_DIR}/vith16.pth.tar",
- alternate_paths=(
- "${WORLDFOUNDRY_HFD_ROOT}/jedi-vjepa/vith16.pth.tar",
- ),
+ alternate_paths=("${WORLDFOUNDRY_HFD_ROOT}/jedi-vjepa/vith16.pth.tar",),
download_url="https://dl.fbaipublicfiles.com/jepa/vith16/vith16.pth.tar",
required_files=("vith16.pth.tar",),
note="V-JEPA ViT-H/16 encoder checkpoint used by VideoJEDi feature extraction.",
@@ -2773,9 +2792,7 @@ def _perception_data_capability(
role="metric_model_file",
env=("WORLDFOUNDRY_JEDI_SSV2_PROBE_CHECKPOINT",),
local_path="${WORLDFOUNDRY_JEDI_MODEL_DIR}/ssv2-probe.pth.tar",
- alternate_paths=(
- "${WORLDFOUNDRY_HFD_ROOT}/jedi-vjepa/ssv2-probe.pth.tar",
- ),
+ alternate_paths=("${WORLDFOUNDRY_HFD_ROOT}/jedi-vjepa/ssv2-probe.pth.tar",),
download_url="https://dl.fbaipublicfiles.com/jepa/vith16/ssv2-probe.pth.tar",
required_files=("ssv2-probe.pth.tar",),
note="Fine-tuned SSV2 attentive probe used by VideoJEDi feature aggregation.",
@@ -3282,7 +3299,9 @@ def _perception_data_capability(
role="checkpoint",
env=("WORLDFOUNDRY_VBENCH_UMT_CKPT",),
local_path="${WORLDFOUNDRY_CACHE_DIR}/models/vbench/umt_model/l16_ptk710_ftk710_ftk400_f16_res224.pth",
- alternate_paths=("${WORLDFOUNDRY_CKPT_DIR}/VBench/umt_model/l16_ptk710_ftk710_ftk400_f16_res224.pth",),
+ alternate_paths=(
+ "${WORLDFOUNDRY_CKPT_DIR}/VBench/umt_model/l16_ptk710_ftk710_ftk400_f16_res224.pth",
+ ),
hf_repo_id="OpenGVLab/VBench_Used_Models",
hf_filename="l16_ptk710_ftk710_ftk400_f16_res224.pth",
min_size_bytes=200_000_000,
@@ -3382,7 +3401,9 @@ def _perception_data_capability(
role="checkpoint",
env=("WORLDFOUNDRY_VBENCH_AESTHETIC_LINEAR_CKPT",),
local_path="${WORLDFOUNDRY_CACHE_DIR}/models/vbench/aesthetic_model/emb_reader/sa_0_4_vit_l_14_linear.pth",
- alternate_paths=("${WORLDFOUNDRY_CKPT_DIR}/VBench/aesthetic_model/emb_reader/sa_0_4_vit_l_14_linear.pth",),
+ alternate_paths=(
+ "${WORLDFOUNDRY_CKPT_DIR}/VBench/aesthetic_model/emb_reader/sa_0_4_vit_l_14_linear.pth",
+ ),
download_url="https://github.com/LAION-AI/aesthetic-predictor/raw/main/sa_0_4_vit_l_14_linear.pth",
min_size_bytes=1_000,
note="LAION aesthetic predictor linear head used by VBench aesthetic quality.",
@@ -3404,7 +3425,9 @@ def _perception_data_capability(
role="checkpoint",
env=("WORLDFOUNDRY_VBENCH_RETINAFACE_CKPT",),
local_path="${WORLDFOUNDRY_CACHE_DIR}/models/vbench/retina_face_model/retinaface_resnet50_2020-07-20-f168fae3c.zip",
- alternate_paths=("${WORLDFOUNDRY_CKPT_DIR}/VBench/retina_face_model/retinaface_resnet50_2020-07-20-f168fae3c.zip",),
+ alternate_paths=(
+ "${WORLDFOUNDRY_CKPT_DIR}/VBench/retina_face_model/retinaface_resnet50_2020-07-20-f168fae3c.zip",
+ ),
download_url="https://github.com/ternaus/retinaface/releases/download/0.01/retinaface_resnet50_2020-07-20-f168fae3c.zip",
min_size_bytes=100_000_000,
required=False,
@@ -3490,7 +3513,9 @@ def _perception_data_capability(
role="checkpoint",
env=("WORLDFOUNDRY_VBENCH2_YOLO_WORLD_CKPT",),
local_path="${WORLDFOUNDRY_CACHE_DIR}/models/vbench2/YOLO-World/yolo_world_v2_xl_obj365v1_goldg_cc3mlite_pretrain-5daf1395.pth",
- alternate_paths=("${WORLDFOUNDRY_CKPT_DIR}/VBench2/YOLO-World/yolo_world_v2_xl_obj365v1_goldg_cc3mlite_pretrain-5daf1395.pth",),
+ alternate_paths=(
+ "${WORLDFOUNDRY_CKPT_DIR}/VBench2/YOLO-World/yolo_world_v2_xl_obj365v1_goldg_cc3mlite_pretrain-5daf1395.pth",
+ ),
required=False,
manual_action=(
"Stage the official VBench-2.0 YOLO-World checkpoint under $WORLDFOUNDRY_VBENCH2_CACHE_DIR "
@@ -4254,10 +4279,7 @@ def base_model_inventory() -> dict[str, Any]:
"stack_count": len(BASE_MODEL_STACKS),
"asset_count": asset_count,
"required_asset_count": sum(
- 1
- for capability in BASE_MODEL_CAPABILITIES.values()
- for asset in capability.assets
- if asset.required
+ 1 for capability in BASE_MODEL_CAPABILITIES.values() for asset in capability.assets if asset.required
),
"capabilities_by_family": _capabilities_by_family(),
"stacks_by_family": _stacks_by_family(),
@@ -4268,13 +4290,9 @@ def base_model_inventory() -> dict[str, Any]:
for benchmark_id, capability_ids in sorted(BENCHMARK_DATA_ASSET_CAPABILITIES.items())
},
"capabilities": [
- BASE_MODEL_CAPABILITIES[capability_id].to_dict()
- for capability_id in sorted(BASE_MODEL_CAPABILITIES)
- ],
- "stacks": [
- BASE_MODEL_STACKS[stack_id].to_dict()
- for stack_id in sorted(BASE_MODEL_STACKS)
+ BASE_MODEL_CAPABILITIES[capability_id].to_dict() for capability_id in sorted(BASE_MODEL_CAPABILITIES)
],
+ "stacks": [BASE_MODEL_STACKS[stack_id].to_dict() for stack_id in sorted(BASE_MODEL_STACKS)],
}
@@ -4607,33 +4625,15 @@ def base_model_materialization_plan(
check_base_model_capability(capability_id, env=env, python_executable=python_executable)
for capability_id in selected_ids
]
- download_commands = [
- command
- for check in checks
- for command in check["repair_hints"]["download_commands"]
- ]
+ download_commands = [command for check in checks for command in check["repair_hints"]["download_commands"]]
download_command_argvs = [
- command
- for check in checks
- for command in check["repair_hints"]["download_command_argvs"]
- ]
- export_commands = [
- command
- for check in checks
- for command in check["repair_hints"]["export_commands"]
+ command for check in checks for command in check["repair_hints"]["download_command_argvs"]
]
+ export_commands = [command for check in checks for command in check["repair_hints"]["export_commands"]]
pip_install_packages = sorted(
- {
- package
- for check in checks
- for package in check["repair_hints"]["pip_install_packages"]
- }
+ {package for check in checks for package in check["repair_hints"]["pip_install_packages"]}
)
- manual_actions = [
- action
- for check in checks
- for action in check["repair_hints"]["manual_actions"]
- ]
+ manual_actions = [action for check in checks for action in check["repair_hints"]["manual_actions"]]
return {
"schema_version": "worldfoundry-base-model-assets-v1",
"ok": all(check["ok"] for check in checks),
diff --git a/worldfoundry/base_models/perception_core/general_perception/dino_embeddings.py b/worldfoundry/base_models/perception_core/general_perception/dino_embeddings.py
index 639c73122..13256eaaf 100644
--- a/worldfoundry/base_models/perception_core/general_perception/dino_embeddings.py
+++ b/worldfoundry/base_models/perception_core/general_perception/dino_embeddings.py
@@ -61,6 +61,25 @@ def load_dino_vitb16_feature_model(
return model.eval()
+def load_dinov2_feature_model(
+ checkpoint: str | Path,
+ *,
+ device: str | torch.device | None = None,
+ use_pooler_output: bool = False,
+ local_files_only: bool = True,
+) -> HFVisionEmbeddingModel:
+ """Load a selected DINOv2 checkpoint with explicit CLS/pooler semantics."""
+ from transformers import AutoModel
+
+ model = HFVisionEmbeddingModel(
+ AutoModel.from_pretrained(str(checkpoint), local_files_only=local_files_only),
+ use_pooler_output=use_pooler_output,
+ )
+ if device is not None:
+ model = model.to(device)
+ return model.eval()
+
+
def load_dinov2_base_feature_model(
*,
env: Mapping[str, str] | None = None,
diff --git a/worldfoundry/data/benchmarks/catalog/video/worldmembench.yaml b/worldfoundry/data/benchmarks/catalog/video/worldmembench.yaml
new file mode 100644
index 000000000..b1238df72
--- /dev/null
+++ b/worldfoundry/data/benchmarks/catalog/video/worldmembench.yaml
@@ -0,0 +1,196 @@
+id: worldmembench
+name: WorldMemBench
+publisher: Zhejiang University
+publisher_kind: university
+benchmark_kind:
+- video-world-model
+- visual-memory
+- camera-control
+domains:
+- world-model
+- human-memory
+- object-memory
+- environment-memory
+- dynamic-memory
+modalities:
+- video
+- text
+status: official_runtime_ready_real_data_validation_pending
+maturity: contract_ready
+release_status: experimental
+open_source_status: experimental
+official_benchmark_verified: false
+integration_evidence: false
+leaderboard_valid: false
+checkpoint_refs: []
+requires:
+- Official result summaries, or generated videos with staged metric checkpoints and annotations.
+blockers:
+- Complete-dataset and checkpoint replay remain pending.
+source:
+ status: open_source
+official_sources:
+ github:
+ url: https://github.com/ZJU-REAL/WorldMemBench
+ revision: 1b79840143a98e017891e893b5d3ef75b93078a3
+ paper:
+ url: https://arxiv.org/abs/2610.19063
+ huggingface_dataset:
+ repo_id: rescuerz/WorldMemBench
+source_provenance:
+ official_repo:
+ url: https://github.com/ZJU-REAL/WorldMemBench
+ revision: 1b79840143a98e017891e893b5d3ef75b93078a3
+ license: MIT
+ role: adapted_official_protocol
+ provenance: Official metric protocols run in tree with shared CLIP, LAION and DINO model components.
+metrics:
+- id: imaging_quality
+ name: Imaging Quality
+ description: Official MUSIQ-SPAQ frame scores, normalized upstream and averaged equally per video.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: aesthetic_quality
+ name: Aesthetic Quality
+ description: Official CLIP/LAION aesthetic frame scores, normalized upstream and averaged per video.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: human_identity
+ name: Human Identity
+ description: Human identity retention across departure and revisit segments.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: human_appearance
+ name: Human Appearance
+ description: Human appearance retention under the official revisit protocol.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: object_geometry
+ name: Object Geometry
+ description: Object geometry retention under the official revisit protocol.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: object_appearance
+ name: Object Appearance
+ description: Object appearance retention under the official revisit protocol.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: environment_visible_memory
+ name: Environment Visible Memory
+ description: DINOv2 full-frame similarity at camera return frames.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: environment_depth_fidelity
+ name: Environment Depth Fidelity
+ description: Pixel-aligned DA3 depth-order Spearman correlation at return frames.
+ higher_is_better: true
+ output_unit: correlation
+ aggregator: mean
+- id: environment_scene_reconstruction
+ name: Environment Scene Reconstruction
+ description: Official scene reconstruction score at departure and revisit frames.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: dynamic_memory
+ name: Dynamic Memory
+ description: Event-gated eight-question VLM checklist score with per-category details retained.
+ higher_is_better: true
+ output_unit: score
+ aggregator: mean
+- id: camera_rotation
+ name: Camera Rotation
+ description: DA3 adjacent rotation RPE normalized by the reference rotation path.
+ higher_is_better: false
+ output_unit: error
+ aggregator: mean
+- id: camera_translation
+ name: Camera Translation
+ description: DA3/ViPE path-normalized translation RPE following Sim(3) alignment.
+ higher_is_better: false
+ output_unit: error
+ aggregator: mean
+integration:
+ status: integrated
+ tier: in_tree
+ evidence:
+ runtime_surface: in_tree_official_runtime_with_result_normalizer
+ full_suite_verified: false
+runner:
+ runner_target: worldfoundry.evaluation.tasks.contracts.external:WorldMemBenchContract
+ official_script: worldfoundry/evaluation/tasks/execution/runners/worldmembench/run_worldmembench_official_runner.py
+ workspace:
+ module: worldfoundry.evaluation.tasks.execution.runners.worldmembench.run_worldmembench_official_runner
+ supports_official_runtime: true
+ accepts_generated_artifacts: true
+ supports_fixture: false
+ model_arg: --model-id
+ metric_arg: --metric
+ normalization:
+ artifact_official_run: true
+ pass_generated_artifact_dir: true
+ runtime:
+ kind: in_tree_official_runtime
+ in_tree_path: worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime
+ repo_url: https://github.com/ZJU-REAL/WorldMemBench
+ repo_revision: 1b79840143a98e017891e893b5d3ef75b93078a3
+ results_path_env: WORLDFOUNDRY_WORLDMEMBENCH_RESULTS_PATH
+ expected_artifacts:
+ - scorecard.json
+ - raw_metric_table.jsonl
+ - per_sample_scores.jsonl
+ verification_status: pending
+ required_env: []
+ optional_env:
+ - WORLDFOUNDRY_WORLDMEMBENCH_RESULTS_PATH
+ - WORLDFOUNDRY_WORLDMEMBENCH_PYTHON
+ - WORLDFOUNDRY_WORLDMEMBENCH_CONFIG
+ - WORLDFOUNDRY_WORLDMEMBENCH_WEIGHTS_DIR
+ - WORLDFOUNDRY_GENERATED_ARTIFACT_DIR
+ notes:
+ - Import the evaluation output root, its metrics directory, or a metric summary.json.
+ - Multi-model outputs require an explicit model selection; scores are never pooled across models.
+ - Evaluation and invalid-data failures retain coverage and produce a nonzero runner exit.
+ - Model failures remain scored according to the official protocol; not-applicable cases are not zero-filled.
+ - Raw metric units and score directions are retained without a cross-metric average.
+data_refs:
+ task_yaml: worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml
+ hf_dataset_ids:
+ - rescuerz/WorldMemBench
+artifact_layout:
+ output_artifact: benchmark_scorecard
+ filename_template: scorecard.json
+runner_availability:
+ available: true
+ runner_target: worldfoundry.evaluation.tasks.contracts.external:WorldMemBenchContract
+ runner_script_available: true
+ task_yaml_available: true
+ runtime_verified: false
+ full_suite_verified: false
+ surface: in_tree_official_runtime_with_result_normalizer
+validation_command:
+- worldfoundry-eval
+- zoo
+- benchmark-run
+- --benchmark-id
+- worldmembench
+- --mode
+- official-validation
+- --official-results-path
+-
+- --output-dir
+-
+- --json
+notes:
+- Source and metric protocol are attributed to ZJU-REAL/WorldMemBench under MIT.
+- All twelve metric protocols execute in tree; result import remains weight-free.
+- CLIP, LAION heads and DINO checkpoint loading reuse canonical components; human/object masked DINO features share one implementation.
+- DA3-Streaming and ViPE pose preparation accept staged artifacts or explicitly configured model runtimes; the WorldMemBench checkout is not required.
+- GPU and full-checkpoint replay are pending; result import does not establish leaderboard validity.
diff --git a/worldfoundry/data/benchmarks/requirements/worldmembench.txt b/worldfoundry/data/benchmarks/requirements/worldmembench.txt
new file mode 100644
index 000000000..562d7fb7d
--- /dev/null
+++ b/worldfoundry/data/benchmarks/requirements/worldmembench.txt
@@ -0,0 +1,13 @@
+# Install into the prepared GPU environment; retain its Torch/CUDA ABI.
+# Quality: pyiqa. Human: ultralytics, insightface, onnxruntime-gpu.
+# Object/environment: transformers. Dynamic Memory uses a configured VLM endpoint.
+numpy>=1.24
+opencv-python-headless>=4.8,<5
+Pillow>=10
+PyYAML>=6
+pyiqa
+transformers>=4.57
+ultralytics>=8.3
+insightface>=0.7
+onnxruntime-gpu>=1.24
+scipy>=1.10
diff --git a/worldfoundry/data/benchmarks/runtime_profiles/official/worldmembench.yaml b/worldfoundry/data/benchmarks/runtime_profiles/official/worldmembench.yaml
new file mode 100644
index 000000000..d93e22bbc
--- /dev/null
+++ b/worldfoundry/data/benchmarks/runtime_profiles/official/worldmembench.yaml
@@ -0,0 +1,69 @@
+id: worldmembench
+name: WorldMemBench
+benchmark_ids:
+- worldmembench
+environment_id: worldfoundry-unified-cu128
+needs_new_env: false
+python_path: ${WORLDFOUNDRY_UNIFIED_PYTHON:-python}
+pythonpath_roots:
+- .
+required_env: []
+optional_env:
+- WORLDFOUNDRY_WORLDMEMBENCH_RESULTS_PATH
+- WORLDFOUNDRY_WORLDMEMBENCH_PYTHON
+- WORLDFOUNDRY_WORLDMEMBENCH_CONFIG
+- WORLDFOUNDRY_WORLDMEMBENCH_WEIGHTS_DIR
+- WORLDFOUNDRY_GENERATED_ARTIFACT_DIR
+required_imports: []
+required_packages: []
+required_paths:
+- id: official_runner_script
+ path: worldfoundry/evaluation/tasks/execution/runners/worldmembench/run_worldmembench_official_runner.py
+- id: in_tree_runtime
+ path: worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/cli.py
+- id: metric_requirements
+ path: worldfoundry/data/benchmarks/requirements/worldmembench.txt
+- id: metrics_module
+ path: worldfoundry/evaluation/tasks/execution/runners/worldmembench/worldmembench_metrics.py
+- id: task_yaml
+ path: worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml
+required_assets:
+ task_yaml: worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml
+ hf_dataset_ids:
+ - rescuerz/WorldMemBench
+requires_cuda_visibility: false
+status: official_runtime_ready_real_data_validation_pending
+notes:
+- Result import uses the unified environment without CUDA, metric checkpoints, or upstream Python dependencies.
+- All twelve official metric protocols execute in tree without an installed worldmembench package or checkout.
+- CLIP, LAION heads and DINO checkpoint loading use shared components; masked human/object DINO inference has one implementation.
+- WORLDFOUNDRY_WORLDMEMBENCH_PYTHON selects the metric interpreter without changing the unified Torch/CUDA ABI.
+- DA3-Streaming and ViPE preparation reuse staged artifacts or explicitly configured model runtimes and interpreters.
+- Dynamic Memory uses the official VLM configuration and API-key environment variable; credentials are not runner arguments.
+- Official dataset/checkpoint replay and leaderboard parity remain pending.
+validation_command: &worldmembench_validation_command
+- worldfoundry-eval
+- zoo
+- benchmark-run
+- --benchmark-id
+- worldmembench
+- --mode
+- official-validation
+- --official-results-path
+-
+- --output-dir
+-
+- --json
+normalization_command: *worldmembench_validation_command
+full_runtime_command:
+- python
+- -m
+- worldfoundry.evaluation.tasks.execution.runners.worldmembench.run_worldmembench_official_runner
+- --run-official
+- --input-path
+-
+- --config
+-
+- --output-dir
+-
+- --json
diff --git a/worldfoundry/data/benchmarks/runtime_profiles/official/worldscore.yaml b/worldfoundry/data/benchmarks/runtime_profiles/official/worldscore.yaml
index 8f125c952..7bfd58695 100644
--- a/worldfoundry/data/benchmarks/runtime_profiles/official/worldscore.yaml
+++ b/worldfoundry/data/benchmarks/runtime_profiles/official/worldscore.yaml
@@ -15,23 +15,23 @@ bounded_official_gpu_validation:
verified: true
scope: bounded_official_gpu_validation
scorecard: tmp/local_open_eval/worldscore_bounded_gpu_validation_after_readiness_layers_20260525/scorecard.json
-blocked_reason: Bounded WorldScore dynamic official GPU validation passed;
- DROID/GroundingDINO/SAM/RAFT/SEA-RAFT/FlowFormer++/VFIMamba are tracked as reusable WorldFoundry base-model
+blocked_reason: Bounded WorldScore dynamic official GPU validation passed;
+ DROID/GroundingDINO/SAM/RAFT/SEA-RAFT/FlowFormer++/VFIMamba are tracked as reusable WorldFoundry base-model
dependencies. Full leaderboard parity still needs complete evaluation.json trees and full GPU metric execution.
environment_id: worldfoundry-unified-cu128
id: worldscore
name: WorldScore
needs_new_env: false
notes:
-- DROID/GroundingDINO/SAM/RAFT/SEA-RAFT/FlowFormer++/VFIMamba checkpoints are now modeled as reusable WorldFoundry
+- DROID/GroundingDINO/SAM/RAFT/SEA-RAFT/FlowFormer++/VFIMamba checkpoints are now modeled as reusable WorldFoundry
base-model dependencies rather than benchmark-private assets.
-- Full leaderboard parity still needs complete evaluation.json trees and full GPU metric execution.
+- Full leaderboard parity still needs complete evaluation.json trees and full GPU metric execution.
CUDA_VISIBLE_DEVICES=7; both cu118 stacks initialized CUDA and completed a one-element tensor sum.
-preflight_command:
+preflight_command:
PYTHONPATH=${WORLDFOUNDRY_REPO_ROOT}:${WORLDFOUNDRY_REPO_ROOT}/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore
${WORLDFOUNDRY_UNIFIED_PYTHON:-python} -m
- worldfoundry.evaluation.tasks.execution.orchestration.runtime_preflight --profile worldscore --manifest
- worldfoundry/data/benchmarks/runtime_profiles/official/worldscore.yaml --output-dir
+ worldfoundry.evaluation.tasks.execution.orchestration.runtime_preflight --profile worldscore --manifest
+ worldfoundry/data/benchmarks/runtime_profiles/official/worldscore.yaml --output-dir
tmp/worldfoundry_preflight/worldscore --json
python_path: ${WORLDFOUNDRY_UNIFIED_PYTHON:-python}
pythonpath_roots:
@@ -57,7 +57,6 @@ required_imports:
- structlog
- submitit
- pyiqa
-- pytorch_lightning
- worldfoundry.base_models.perception_core.detection.grounding_dino
- worldfoundry.base_models.perception_core.segment.sam2
- worldfoundry.base_models.perception_core.segment.sam_v1
@@ -95,7 +94,6 @@ required_packages:
- structlog
- submitit
- pyiqa
-- pytorch-lightning
- lietorch
- spacy
- cvxpy
@@ -142,20 +140,20 @@ base_model_dependency_preflight:
- vfimamba
ready_env:
WORLDFOUNDRY_DROID_SLAM_CKPT: cache/worldfoundry/assets/worldscore_official/metrics/checkpoints/droid.pth
- WORLDFOUNDRY_FLOWFORMERPLUSPLUS_CKPT:
+ WORLDFOUNDRY_FLOWFORMERPLUSPLUS_CKPT:
cache/worldfoundry/assets/worldscore_official/metrics/checkpoints/things_288960.pth
WORLDFOUNDRY_GROUNDING_DINO_CKPT: cache/hfd/ShilongLiu--GroundingDINO/groundingdino_swint_ogc.pth
- WORLDFOUNDRY_RAFT_THINGS_CKPT:
+ WORLDFOUNDRY_RAFT_THINGS_CKPT:
cache/worldfoundry/assets/worldscore_official/metrics/checkpoints/models/raft-things.pth
WORLDFOUNDRY_SAM_VIT_H_CKPT: cache/worldfoundry/assets/worldscore_official/metrics/checkpoints/sam_vit_h_4b8939.pth
WORLDFOUNDRY_SAM2_CKPT: cache/hfd/facebook--sam2.1-hiera-base-plus/sam2.1_hiera_base_plus.pt
- WORLDFOUNDRY_SEA_RAFT_CKPT:
+ WORLDFOUNDRY_SEA_RAFT_CKPT:
cache/worldfoundry/assets/worldscore_official/metrics/checkpoints/Tartan-C-T-TSKH-spring540x960-M.pth
WORLDFOUNDRY_VFIMAMBA_CKPT: ckpt/WorldScore/VFIMamba.pkl
runtime_env:
- WORLDFOUNDRY_WORLDSCORE_ROOT:
+ WORLDFOUNDRY_WORLDSCORE_ROOT:
${WORLDFOUNDRY_REPO_ROOT}/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore
- WORLDFOUNDRY_WORLDSCORE_CONFIG_ROOT:
+ WORLDFOUNDRY_WORLDSCORE_CONFIG_ROOT:
${WORLDFOUNDRY_REPO_ROOT}/worldfoundry/data/benchmarks/assets/worldscore/config
WORLDFOUNDRY_WORLDSCORE_ASSET_CHECKPOINT_DIR: cache/worldfoundry/assets/worldscore_official/metrics/checkpoints
missing: []
diff --git a/worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml b/worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml
new file mode 100644
index 000000000..e7b714a88
--- /dev/null
+++ b/worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml
@@ -0,0 +1,172 @@
+benchmark: worldmembench
+name: WorldMemBench
+description: Official WorldMemBench memory and camera-control evaluation for video world models.
+tags:
+- benchmark-zoo
+- external-official
+- worldmembench
+- video-world-model
+- visual-memory
+splits:
+- standard
+metrics:
+ imaging_quality:
+ aggregation: mean
+ aesthetic_quality:
+ aggregation: mean
+ human_identity:
+ aggregation: mean
+ human_appearance:
+ aggregation: mean
+ object_geometry:
+ aggregation: mean
+ object_appearance:
+ aggregation: mean
+ environment_visible_memory:
+ aggregation: mean
+ environment_depth_fidelity:
+ aggregation: mean
+ environment_scene_reconstruction:
+ aggregation: mean
+ dynamic_memory:
+ aggregation: mean
+ camera_rotation:
+ aggregation: mean
+ camera_translation:
+ aggregation: mean
+metadata:
+ source_kind: benchmark_zoo
+ benchmark_id: worldmembench
+ official_runner_target: worldfoundry.evaluation.tasks.contracts.external:WorldMemBenchContract
+ official_runner_script: worldfoundry/evaluation/tasks/execution/runners/worldmembench/run_worldmembench_official_runner.py
+ official_runner_command: worldfoundry-eval zoo benchmark-run
+ contract_validation_command: worldfoundry-eval zoo benchmark-run --benchmark-id worldmembench --mode contract --output-dir tmp/benchmark_zoo/benchmark_contract/worldmembench --json
+ task_type: video_world_model_memory_evaluation
+ status: integrated
+ maturity: contract_ready
+ release_status: experimental
+ open_source_status: experimental
+ checkpoint_refs: []
+ requires:
+ - Official result summaries, or generated videos with a provisioned upstream runtime and selected metric assets.
+ blockers:
+ - Complete-dataset and checkpoint replay remain pending.
+ validation_command:
+ - worldfoundry-eval
+ - zoo
+ - benchmark-run
+ - --benchmark-id
+ - worldmembench
+ - --mode
+ - official-validation
+ - --official-results-path
+ -
+ - --output-dir
+ -
+ - --json
+ official_benchmark_verified: false
+ integration_evidence: false
+ leaderboard_valid: false
+ source_provenance:
+ official_repo:
+ url: https://github.com/ZJU-REAL/WorldMemBench
+ revision: 1b79840143a98e017891e893b5d3ef75b93078a3
+ license: MIT
+ role: external_runtime_dependency
+ paper:
+ url: https://arxiv.org/abs/2610.19063
+ huggingface_dataset:
+ repo_id: rescuerz/WorldMemBench
+ input_contract:
+ - generated_video_dir
+ - official_results_path
+ - worldmembench_config
+ - weights_dir
+ output_contract:
+ - scorecard
+ - raw_metric_table
+ - per_sample_scores
+ - official_metric_summaries
+ evaluation_requirements:
+ - Use official JSONL manifests or the upstream model/category/trajectory/case video layout.
+ - Import official metrics//summary.json outputs or execute a provisioned upstream runtime.
+ - Keep one model group per scorecard and retain official per-case statuses and camera-error directions.
+ - Official metric computation requires the selected checkpoint stacks and DA3/ViPE environments; Dynamic Memory requires a VLM endpoint.
+ data_refs:
+ task_yaml: worldfoundry/data/benchmarks/tasks/external/worldmembench.yaml
+ hf_dataset_ids:
+ - rescuerz/WorldMemBench
+ runner_availability:
+ available: true
+ runner_target: worldfoundry.evaluation.tasks.contracts.external:WorldMemBenchContract
+ runner_script_available: true
+ task_yaml_available: true
+ runtime_verified: false
+ full_suite_verified: false
+ surface: external_official_runtime_with_result_normalizer
+ runtime:
+ kind: external_official_repo
+ root_env: WORLDFOUNDRY_WORLDMEMBENCH_ROOT
+ results_path_env: WORLDFOUNDRY_WORLDMEMBENCH_RESULTS_PATH
+ artifact_layout:
+ generated_artifact_dir_env: WORLDFOUNDRY_GENERATED_ARTIFACT_DIR
+ output_artifact: benchmark_scorecard
+ filename_template: scorecard.json
+tasks:
+ worldmembench_standard:
+ benchmark_name: worldmembench
+ protocol: external_official_benchmark
+ capability_track: video_world_model_memory
+ schema_type: generated_artifact_set
+ input_keys:
+ - generated_video_dir
+ - official_results_path
+ - worldmembench_config
+ - weights_dir
+ output_keys:
+ - scorecard
+ - raw_metric_table
+ - per_sample_scores
+ - official_metric_summaries
+ metric_ids: &worldmembench_metric_ids
+ - imaging_quality
+ - aesthetic_quality
+ - human_identity
+ - human_appearance
+ - object_geometry
+ - object_appearance
+ - environment_visible_memory
+ - environment_depth_fidelity
+ - environment_scene_reconstruction
+ - dynamic_memory
+ - camera_rotation
+ - camera_translation
+ tags:
+ - benchmark-zoo
+ - external-official
+ - worldmembench
+ - video-world-model
+ metadata:
+ source_kind: benchmark_zoo
+ benchmark_id: worldmembench
+ official_benchmark_verified: false
+ integration_evidence: false
+ leaderboard_valid: false
+ dataset:
+ hf_dataset_id: rescuerz/WorldMemBench
+ split: standard
+ runtime_requirements:
+ generated_artifact_dir_env: WORLDFOUNDRY_GENERATED_ARTIFACT_DIR
+ results_path_env: WORLDFOUNDRY_WORLDMEMBENCH_RESULTS_PATH
+ artifact_contract:
+ required:
+ generated_video:
+ kind: video
+ optional:
+ official_results_path:
+ kind: directory
+ evaluation_protocol:
+ - name: external_official_runner
+ metric_ids: *worldmembench_metric_ids
+ runner_target: worldfoundry.evaluation.tasks.contracts.external:WorldMemBenchContract
+ runner_script: worldfoundry/evaluation/tasks/execution/runners/worldmembench/run_worldmembench_official_runner.py
diff --git a/worldfoundry/evaluation/tasks/contracts/external.py b/worldfoundry/evaluation/tasks/contracts/external.py
index 148626fef..41189a843 100644
--- a/worldfoundry/evaluation/tasks/contracts/external.py
+++ b/worldfoundry/evaluation/tasks/contracts/external.py
@@ -22,7 +22,6 @@
register_external_benchmark_contract,
)
-
ROBOTWIN_BENCHMARK_ID = "robotwin"
ROBOTWIN_BENCHMARK_NAME = "RoboTwin 2.0"
ROBOTWIN_ROOT_ENV = "WORLDFOUNDRY_ROBOTWIN_ROOT"
@@ -856,6 +855,33 @@ def build_robotwin_contract_payload(benchmark_id: str = ROBOTWIN_BENCHMARK_ID) -
),
)
+WorldMemBenchContract = ExternalBenchmarkContract(
+ benchmark_id="worldmembench",
+ display_name="WorldMemBench",
+ input_keys=("generated_video_dir", "official_results_path", "worldmembench_config", "weights_dir"),
+ output_keys=("scorecard", "raw_metric_table", "per_sample_scores", "official_metric_summaries"),
+ metric_ids=(
+ "imaging_quality",
+ "aesthetic_quality",
+ "human_identity",
+ "human_appearance",
+ "object_geometry",
+ "object_appearance",
+ "environment_visible_memory",
+ "environment_depth_fidelity",
+ "environment_scene_reconstruction",
+ "dynamic_memory",
+ "camera_rotation",
+ "camera_translation",
+ ),
+ requires_upstream_runtime=True,
+ notes=(
+ "Imports official per-model summaries or runs all twelve metric protocols in tree with shared model components.",
+ "Scores retain official units, case statuses, and camera-error directions; no composite score is defined.",
+ "Full evaluation needs metric checkpoints, staged or configured DA3-Streaming/ViPE artifacts, and a Dynamic Memory VLM endpoint.",
+ ),
+)
+
MemoBenchContract = ExternalBenchmarkContract(
benchmark_id="memobench",
display_name="MemoBench",
@@ -1061,7 +1087,12 @@ def build_robotwin_contract_payload(benchmark_id: str = ROBOTWIN_BENCHMARK_ID) -
IPVBenchContract = ExternalBenchmarkContract(
benchmark_id="ipv-bench",
display_name="IPV-Bench",
- input_keys=("impossible_prompt_manifest", "generated_video_dir", "understanding_results_path", "official_results_path"),
+ input_keys=(
+ "impossible_prompt_manifest",
+ "generated_video_dir",
+ "understanding_results_path",
+ "official_results_path",
+ ),
output_keys=("scorecard", "raw_metric_table", "per_sample_scores", "benchmark_contract"),
metric_ids=(
"visual_quality",
@@ -1155,7 +1186,12 @@ def build_robotwin_contract_payload(benchmark_id: str = ROBOTWIN_BENCHMARK_ID) -
PhyEduVideoContract = ExternalBenchmarkContract(
benchmark_id="phyeduvideo",
display_name="PhyEduVideo",
- input_keys=("physics_education_prompt_manifest", "generated_video_dir", "judge_results_path", "official_results_path"),
+ input_keys=(
+ "physics_education_prompt_manifest",
+ "generated_video_dir",
+ "judge_results_path",
+ "official_results_path",
+ ),
output_keys=("scorecard", "raw_metric_table", "per_sample_scores", "benchmark_contract"),
metric_ids=(
"semantic_adherence",
@@ -1213,7 +1249,12 @@ def build_robotwin_contract_payload(benchmark_id: str = ROBOTWIN_BENCHMARK_ID) -
WorldInWorldContract = ExternalBenchmarkContract(
benchmark_id="world-in-world",
display_name="World-in-World",
- input_keys=("closed_loop_task_manifest", "world_model_rollouts_dir", "planner_results_path", "official_results_path"),
+ input_keys=(
+ "closed_loop_task_manifest",
+ "world_model_rollouts_dir",
+ "planner_results_path",
+ "official_results_path",
+ ),
output_keys=("scorecard", "raw_metric_table", "rollout_logs", "benchmark_contract"),
metric_ids=(
"active_recognition_success_rate",
@@ -1604,15 +1645,30 @@ def _embodied_action_contract(
Behavior1KContract = _embodied_action_contract(
"behavior1k",
"BEHAVIOR-1K",
- input_keys=("behavior_dataset_root", "simulator_root", "task_manifest", "policy_results_path", "official_results_path"),
+ input_keys=(
+ "behavior_dataset_root",
+ "simulator_root",
+ "task_manifest",
+ "policy_results_path",
+ "official_results_path",
+ ),
metric_ids=("success_rate", "task_success", "episode_success", "subgoal_success", "reward"),
notes=("BEHAVIOR-1K requires OmniGibson/Isaac Sim assets and license-gated simulator setup.",),
)
KinetixContract = _embodied_action_contract(
"kinetix",
"Kinetix",
- metric_ids=("success_rate", "task_success", "episode_success", "reward", "normalized_return", "real_time_success_rate"),
- notes=("Kinetix uses a JAX 2D physics runtime; keep it in a simulator-specific environment when the unified PyTorch environment is insufficient.",),
+ metric_ids=(
+ "success_rate",
+ "task_success",
+ "episode_success",
+ "reward",
+ "normalized_return",
+ "real_time_success_rate",
+ ),
+ notes=(
+ "Kinetix uses a JAX 2D physics runtime; keep it in a simulator-specific environment when the unified PyTorch environment is insufficient.",
+ ),
)
LIBEROMemContract = _embodied_action_contract(
"libero-mem",
@@ -1651,7 +1707,13 @@ def _embodied_action_contract(
MolmoSpacesContract = _embodied_action_contract(
"molmospaces",
"MolmoSpaces-Bench",
- input_keys=("molmospaces_asset_root", "simulator_root", "task_manifest", "policy_results_path", "official_results_path"),
+ input_keys=(
+ "molmospaces_asset_root",
+ "simulator_root",
+ "task_manifest",
+ "policy_results_path",
+ "official_results_path",
+ ),
metric_ids=("success_rate", "pick_success_rate", "task_success", "episode_success", "reward"),
notes=("MolmoSpaces assets are large; stage only the benchmark subset required by the selected suite.",),
)
@@ -1664,7 +1726,13 @@ def _embodied_action_contract(
"robomme",
"RoboMME",
input_keys=("dataset_root", "simulator_root", "memory_manifest", "policy_results_path", "official_results_path"),
- metric_ids=("success_rate", "counting_success_rate", "permanence_success_rate", "reference_success_rate", "imitation_success_rate"),
+ metric_ids=(
+ "success_rate",
+ "counting_success_rate",
+ "permanence_success_rate",
+ "reference_success_rate",
+ "imitation_success_rate",
+ ),
)
VLABenchContract = _embodied_action_contract(
"vlabench",
@@ -1792,6 +1860,7 @@ def _embodied_action_contract(
WorldArenaContract,
WorldBenchContract,
WorldInWorldContract,
+ WorldMemBenchContract,
WorldModelBenchContract,
HarnessEvalWContract,
WorldOlympiadContract,
@@ -1834,8 +1903,4 @@ def list_external_benchmark_contracts() -> tuple[ExternalBenchmarkContract, ...]
return _CONTRACT_REGISTRY.list()
-__all__ = sorted(
- name
- for name in globals()
- if not name.startswith("_") and name not in {"Path", "annotations"}
-)
+__all__ = sorted(name for name in globals() if not name.startswith("_") and name not in {"Path", "annotations"})
diff --git a/worldfoundry/evaluation/tasks/execution/framework/official_runner.py b/worldfoundry/evaluation/tasks/execution/framework/official_runner.py
index 0cf2c483c..249f37b13 100644
--- a/worldfoundry/evaluation/tasks/execution/framework/official_runner.py
+++ b/worldfoundry/evaluation/tasks/execution/framework/official_runner.py
@@ -29,8 +29,8 @@
from types import MappingProxyType
from typing import Any, Callable, Mapping
-from worldfoundry.core.observability.logging_setup import configure_logging, get_logger
from worldfoundry.core.execution.process import run_logged_subprocess
+from worldfoundry.core.observability.logging_setup import configure_logging, get_logger
from worldfoundry.evaluation.reporting.scorecard import SCORECARD_SCHEMA_VERSION
from worldfoundry.evaluation.tasks.catalog.zoo_registry import load_benchmark_zoo_registry
from worldfoundry.evaluation.tasks.execution.framework.io import (
@@ -49,7 +49,6 @@
from worldfoundry.evaluation.tasks.execution.framework.result_normalizer import OfficialResultsNormalizer
from worldfoundry.evaluation.utils import BENCHMARK_ZOO_DIR, REPO_ROOT
-
ExtractMetricsFn = Callable[[Any, Path], dict[str, dict[str, Any]]]
DiscoverResultsFn = Callable[[Path, Path | None], Path | None]
BuildCommandFn = Callable[..., list[str] | None]
@@ -69,7 +68,7 @@ class BenchRunnerConfig:
metric_order: tuple[str, ...]
metric_specs: dict[str, dict[str, Any]]
metric_aliases: dict[str, str]
- average_metric_id: str
+ average_metric_id: str | None
official_entry: str | None = None
official_output_globs: tuple[str, ...] = ()
generated_video_envs: tuple[str, ...] = ("WORLDFOUNDRY_GENERATED_ARTIFACT_DIR",)
@@ -248,9 +247,7 @@ def metric_id_from_key(key: Any, config: BenchRunnerConfig) -> str | None:
# only trusted when every fuzzy hit agrees on one metric id; ambiguous keys
# are dropped instead of being attached to whichever alias iterates first.
fuzzy_hits = {
- metric_id
- for alias, metric_id in config.metric_aliases.items()
- if alias in normalized or normalized in alias
+ metric_id for alias, metric_id in config.metric_aliases.items() if alias in normalized or normalized in alias
}
if len(fuzzy_hits) != 1:
if len(fuzzy_hits) > 1 and (config.benchmark_id, normalized) not in _FUZZY_ALIAS_WARNED:
@@ -291,9 +288,7 @@ def declared_metric_normalizers(benchmark_id: str) -> Mapping[str, str]:
try:
entry = load_benchmark_zoo_registry(BENCHMARK_ZOO_DIR).get(benchmark_id)
declared = {
- metric.metric_id: str(metric.normalizer)
- for metric in entry.metrics
- if getattr(metric, "normalizer", None)
+ metric.metric_id: str(metric.normalizer) for metric in entry.metrics if getattr(metric, "normalizer", None)
}
except Exception: # noqa: BLE001 - uncataloged benchmarks fall back to the legacy heuristic.
declared = {}
@@ -417,6 +412,8 @@ def catalog_fallback(
def compute_average(config: BenchRunnerConfig, extracted: dict[str, dict[str, Any]]) -> None:
+ if config.average_metric_id is None:
+ return
existing = extracted.get(config.average_metric_id)
if existing is not None and existing.get("normalized_score") is not None:
return
@@ -447,9 +444,7 @@ def metric_row(
config: BenchRunnerConfig | None = None,
) -> dict[str, Any]:
normalized = (
- normalized_metric_score(config, metric_id, raw_score)
- if config is not None
- else normalize_unit_score(raw_score)
+ normalized_metric_score(config, metric_id, raw_score) if config is not None else normalize_unit_score(raw_score)
)
return {
"metric_id": metric_id,
@@ -479,7 +474,7 @@ def merge_metric_id_score_rows(
for metric_id, values in buckets.items():
extracted[metric_id] = metric_row(
metric_id,
- mean_numeric(values),
+ sum(values) / len(values),
source=str(results_path),
sample_count=len(values),
config=config,
@@ -609,9 +604,7 @@ def metric_group(metric_id: str) -> str:
for metric_id in metric_order
}
sample_display_name = (
- f"sample_results{Path(sample_filename).suffix}"
- if sample_filename is not None
- else "sample_results.*"
+ f"sample_results{Path(sample_filename).suffix}" if sample_filename is not None else "sample_results.*"
)
return BenchRunnerConfig(
benchmark_id=benchmark_id,
@@ -831,9 +824,7 @@ def run_official_pipeline(
if results_path is not None and hooks.prepare_upstream_results is not None:
results_path = hooks.prepare_upstream_results(config, results_path, args, output_dir)
- extract_fn = hooks.extract_metrics or (
- lambda payload, path: generic_extract_metrics(payload, config, str(path))
- )
+ extract_fn = hooks.extract_metrics or (lambda payload, path: generic_extract_metrics(payload, config, str(path)))
if results_path is not None:
payload, _fmt = load_upstream_payload(results_path)
@@ -1126,7 +1117,9 @@ def run_main(config: BenchRunnerConfig, hooks: RunnerHooks, argv: list[str] | No
sample_path = benchmark_task_sample_path(config.benchmark_id)
if sample_path is None:
logger.event("ERROR", "official_runner.fixture_missing", "Official runner fixture is unavailable")
- reason = f"no checked-in sample for {config.benchmark_id} under {BENCHMARK_ASSETS_ROOT / config.benchmark_id}"
+ reason = (
+ f"no checked-in sample for {config.benchmark_id} under {BENCHMARK_ASSETS_ROOT / config.benchmark_id}"
+ )
write_failed_scorecard(
benchmark_id=config.benchmark_id,
display_name=config.display_name,
@@ -1144,9 +1137,7 @@ def run_main(config: BenchRunnerConfig, hooks: RunnerHooks, argv: list[str] | No
env_results = resolve_results_path(config, None)
if env_results is None:
logger.event("ERROR", "official_runner.results_missing", "Official runner results path is unavailable")
- reason = (
- f"--official-results-path or {config.results_path_env} is required unless --run-official is set"
- )
+ reason = f"--official-results-path or {config.results_path_env} is required unless --run-official is set"
write_failed_scorecard(
benchmark_id=config.benchmark_id,
display_name=config.display_name,
diff --git a/worldfoundry/evaluation/tasks/execution/runners/four_d_worldbench/runtime/four_d_worldbench/metric/base_metrics.py b/worldfoundry/evaluation/tasks/execution/runners/four_d_worldbench/runtime/four_d_worldbench/metric/base_metrics.py
index 2a20e2411..d98f77736 100644
--- a/worldfoundry/evaluation/tasks/execution/runners/four_d_worldbench/runtime/four_d_worldbench/metric/base_metrics.py
+++ b/worldfoundry/evaluation/tasks/execution/runners/four_d_worldbench/runtime/four_d_worldbench/metric/base_metrics.py
@@ -1,114 +1,30 @@
-from abc import ABC, abstractmethod
-from typing import List, Union
+"""4DWorldBench image metrics backed by the shared IQA implementation."""
-import pyiqa
-import pyiqa.models
-import pyiqa.models.inference_model
-import torch
-from PIL import Image
-from pyiqa.models.inference_model import InferenceModel
-from torchvision import transforms
-from torchvision.transforms import ToTensor
-from typing import Tuple
-import numpy as np
+from typing import Any
+
+from worldfoundry.evaluation.tasks.metrics._shared.pyiqa import ImageQualityMetric, PyIQAMetric
+
+
+def get_torch_device() -> Any:
+ """Retain the benchmark's CUDA, MPS, then CPU device preference."""
+ import torch
-def get_torch_device():
- """Return a torch device string preferring CUDA when available."""
if torch.cuda.is_available():
return torch.device("cuda")
if torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
-def open_image(path: str) -> Image.Image:
- """Open image from disk as RGB using PIL."""
- with Image.open(path) as im:
- return im.convert('RGB')
-
-class BaseMetric(ABC):
- """BaseMetric Class."""
+class BaseMetric(ImageQualityMetric):
+ """Use the benchmark's device preference for image comparisons."""
def __init__(self) -> None:
- self._metric = None
- self._device = get_torch_device()
-
- def _process_image(
- self,
- rendered_images: List[Union[str, Image.Image]],
- ) -> float:
- preprocessing = transforms.Compose(
- [
- transforms.Resize((512, 512)),
- transforms.ToTensor(),
- ]
- )
-
- rendered_images_: List[torch.Tensor] = []
- for image in rendered_images:
- # Handle the rendered image input
- if isinstance(image, str):
- image = preprocessing(open_image(image))
- else:
- image = preprocessing(image)
- rendered_images_.append(image)
-
- img: torch.Tensor = torch.stack(rendered_images_).to(self._device)
-
- return img
+ super().__init__(device=get_torch_device())
- def _process_images(
- self,
- rendered_images: List[Union[str, Image.Image]],
- reference_image: Union[str, Image.Image],
- ) -> float:
- preprocessing = transforms.Compose(
- [
- transforms.Resize((512, 512)),
- transforms.ToTensor(),
- ]
- )
- # Handle the reference image input
- if isinstance(reference_image, str):
- reference_image = preprocessing(open_image(reference_image))
+class IQAPytorchMetric(PyIQAMetric):
+ """Apply 4DWorldBench's input policy to the selected PyIQA metric."""
- rendered_images_: List[torch.Tensor] = []
- reference_images_: List[torch.Tensor] = []
- for image in rendered_images:
- # Handle the rendered image input
- if isinstance(image, str):
- image = preprocessing(open_image(image))
- else:
- image = preprocessing(image)
- rendered_images_.append(image)
-
- reference_images_.append(reference_image)
-
- img1: torch.Tensor = torch.stack(rendered_images_).to(self._device)
- img2: torch.Tensor = torch.stack(reference_images_).to(self._device)
-
- return img1, img2
-
- def _process_np_to_tensor(
- self,
- rendered_image: np.ndarray,
- reference_image: np.ndarray,
- ) -> Tuple[torch.Tensor, torch.Tensor]:
- img1 = ToTensor()(rendered_image).unsqueeze(0).to(self._device)
- img2 = ToTensor()(reference_image).unsqueeze(0).to(self._device)
- return img1, img2
-
- @abstractmethod
- def _compute_scores(self, *args):
- pass
-
-
-class IQAPytorchMetric(BaseMetric):
def __init__(self, metric_name: str) -> None:
- super().__init__()
- self._metric = self._create_metric(metric_name).to(self._device)
-
- def _create_metric(self, metric: str) -> InferenceModel:
- metric = pyiqa.create_metric(metric)
- return metric
+ super().__init__(metric_name, device=get_torch_device())
diff --git a/worldfoundry/evaluation/tasks/execution/runners/mind/runtime/mind/src/process.py b/worldfoundry/evaluation/tasks/execution/runners/mind/runtime/mind/src/process.py
index 1b8ea79cd..d748135a7 100644
--- a/worldfoundry/evaluation/tasks/execution/runners/mind/runtime/mind/src/process.py
+++ b/worldfoundry/evaluation/tasks/execution/runners/mind/runtime/mind/src/process.py
@@ -1,30 +1,54 @@
-import lpips
-import torch
-from pyiqa.archs.musiq_arch import MUSIQ
-from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
-from utils.utils import load_time_from_json, print_gpu_memory, get_musiq_spaq_path, get_vitl_path, get_aes_path, extract_actions_from_json, ensure_all_models_downloaded, CACHE_DIR, VideoStreamReader, get_video_length
-from utils.dino_utils import load_dinov3_model, extract_dinov3_features
-from tqdm import tqdm
-import threading
-import torch.nn.functional as F
import json
-import clip
import os
-from pathlib import Path
-import torch.multiprocessing as mp
-import warnings
-from metrics.action import action_accuracy_metric
-from metrics.lcm import lcm_metric, merge_lcm_results
-from metrics.visual_quality import visual_quality_metric, merge_visual_results
-from metrics.dino import dino_mse_metric, merge_dino_results
+import threading
import time
+import warnings
from collections import namedtuple
+from queue import Empty
-Task = namedtuple('Task', ['data', 'retry_count'])
-
-def compute_metrics_single_gpu(task_queue, result_list, gt_root, test_root, dino_path,
- requested_metrics, video_max_time, process_batch_size, device, gpu_id, stop_event):
+import lpips
+import torch
+import torch.multiprocessing as mp
+from metrics.action import action_accuracy_metric
+from metrics.dino import dino_mse_metric, merge_dino_results
+from metrics.lcm import lcm_metric, merge_lcm_results
+from metrics.visual_quality import merge_visual_results, visual_quality_metric
+from pyiqa.archs.musiq_arch import MUSIQ
+from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
+from tqdm import tqdm
+from utils.dino_utils import load_dinov3_model
+from utils.utils import (
+ VideoStreamReader,
+ ensure_all_models_downloaded,
+ extract_actions_from_json,
+ get_aes_path,
+ get_musiq_spaq_path,
+ get_video_length,
+ get_vitl_path,
+ load_time_from_json,
+)
+
+from worldfoundry.base_models.perception_core.general_perception import openai_clip as clip
+from worldfoundry.evaluation.tasks.metrics._shared.aesthetic import load_laion_aesthetic_linear_head
+
+Task = namedtuple("Task", ["data", "retry_count"])
+
+
+def compute_metrics_single_gpu(
+ task_queue,
+ result_list,
+ gt_root,
+ test_root,
+ dino_path,
+ requested_metrics,
+ video_max_time,
+ process_batch_size,
+ device,
+ gpu_id,
+ stop_event,
+):
import warnings
+
warnings.filterwarnings("ignore", message=".*pretrained.*")
warnings.filterwarnings("ignore", message=".*Weights.*")
warnings.filterwarnings("ignore", message=".*video.*deprecated.*")
@@ -34,28 +58,23 @@ def compute_metrics_single_gpu(task_queue, result_list, gt_root, test_root, dino
在单个GPU上从任务队列获取并处理任务
"""
# 初始化模型
- if 'lcm' in requested_metrics or 'gsc' in requested_metrics:
+ if "lcm" in requested_metrics or "gsc" in requested_metrics:
tqdm.write(f"GPU[{gpu_id}]: loading lcm model")
- lpips_metric = lpips.LPIPS(net='alex', spatial=False).to(device)
- ssim_metric = StructuralSimilarityIndexMeasure(data_range=1.0, reduction='none').to(device)
- psnr_metric = PeakSignalNoiseRatio(data_range=1.0, reduction='none', dim=[1, 2, 3]).to(device)
+ lpips_metric = lpips.LPIPS(net="alex", spatial=False).to(device)
+ ssim_metric = StructuralSimilarityIndexMeasure(data_range=1.0, reduction="none").to(device)
+ psnr_metric = PeakSignalNoiseRatio(data_range=1.0, reduction="none", dim=[1, 2, 3]).to(device)
- if 'visual' in requested_metrics:
+ if "visual" in requested_metrics:
tqdm.write(f"GPU[{gpu_id}]: loading visual quality model")
imaging_model = MUSIQ(pretrained_model_path=get_musiq_spaq_path())
- aesthetic_model = torch.nn.Linear(768, 1)
+ aesthetic_model = load_laion_aesthetic_linear_head(get_aes_path()).to(device)
clip_model, preprocess = clip.load(get_vitl_path(), device=device)
- s = torch.load(get_aes_path(), weights_only=False)
- aesthetic_model.load_state_dict(s)
- aesthetic_model.to(device)
- aesthetic_model.eval()
-
imaging_model.to(device)
imaging_model.training = False
# 加载DINOv3模型
- if 'dino' in requested_metrics:
+ if "dino" in requested_metrics:
tqdm.write(f"GPU[{gpu_id}]: loading dinov3 model")
dino_model, dino_processor = load_dinov3_model(dino_path, device)
@@ -72,72 +91,85 @@ def compute_metrics_single_gpu(task_queue, result_list, gt_root, test_root, dino
task = task_queue.get(timeout=5)
data = task.data
retry_count = task.retry_count
- except:
+ except Empty:
continue
- data_path = data['path']
- gt_dir = os.path.join(gt_root, data['perspective'], 'test', data['test_type'])
- test_dir = os.path.join(test_root, data['perspective'], data['test_type'])
+ data_path = data["path"]
+ gt_dir = os.path.join(gt_root, data["perspective"], "test", data["test_type"])
+ test_dir = os.path.join(test_root, data["perspective"], data["test_type"])
prefix = f"[GPU{gpu_id}] {data_path}"
try:
- if data['test_type'] == 'mirror_test':
- if 'gsc' not in requested_metrics:
+ if data["test_type"] == "mirror_test":
+ if "gsc" not in requested_metrics:
continue
result = data.copy()
- path_videos = [v for v in os.listdir(os.path.join(test_dir, data_path)) if v.endswith('.mp4')]
- result['video_results'] = []
- result['videos'] = path_videos
- result['error'] = None
+ path_videos = [v for v in os.listdir(os.path.join(test_dir, data_path)) if v.endswith(".mp4")]
+ result["video_results"] = []
+ result["videos"] = path_videos
+ result["error"] = None
for video in path_videos:
- vid_result = {'video_name': video, 'error': None}
+ vid_result = {"video_name": video, "error": None}
sample_frames = get_video_length(os.path.join(test_dir, data_path, video))
- vid_result['mark_time'] = sample_frames // 2
- vid_result['sample_frames'] = sample_frames
- if sample_frames%2 == 1:
- vid_result['error'] = 'frame count is not an even number!'
+ vid_result["mark_time"] = sample_frames // 2
+ vid_result["sample_frames"] = sample_frames
+ if sample_frames % 2 == 1:
+ vid_result["error"] = "frame count is not an even number!"
tqdm.write(f"{prefix}: Task failed because frame count is not an even number!")
- result['video_results'].append(vid_result)
+ result["video_results"].append(vid_result)
continue
tqdm.write(f"{prefix}: [1/2] Reading videos...")
- sample_reader = VideoStreamReader(os.path.join(test_dir, data_path, video), start_frame=0, total_frames=sample_frames)
+ sample_reader = VideoStreamReader(
+ os.path.join(test_dir, data_path, video), start_frame=0, total_frames=sample_frames
+ )
_, imgs = sample_reader.read_batch(sample_frames)
- origin_pred = imgs[:sample_frames // 2]
- mirror_pred = torch.flip(imgs[sample_frames // 2:], dims=[0])
+ origin_pred = imgs[: sample_frames // 2]
+ mirror_pred = torch.flip(imgs[sample_frames // 2 :], dims=[0])
tqdm.write(f"{prefix}: part len: {len(origin_pred)}, {len(mirror_pred)}")
-
+
tqdm.write(f"{prefix}: [2/2] Computing GSC metrics (MSE/PSNR/SSIM/LPIPS)...")
- gsc = lcm_metric(origin_pred, mirror_pred, lpips_metric, ssim_metric, psnr_metric, process_batch_size, device)
- vid_result['gsc'] = gsc
- result['video_results'].append(vid_result)
+ gsc = lcm_metric(
+ origin_pred, mirror_pred, lpips_metric, ssim_metric, psnr_metric, process_batch_size, device
+ )
+ vid_result["gsc"] = gsc
+ result["video_results"].append(vid_result)
- del sample_reader
+ del sample_reader
torch.cuda.empty_cache()
else:
- mark_time, total_time = load_time_from_json(os.path.join(gt_dir, data_path, 'action.json'))
+ mark_time, total_time = load_time_from_json(os.path.join(gt_dir, data_path, "action.json"))
result = data.copy()
- result['error'] = None
- result['mark_time'] = mark_time
- result['total_time'] = total_time
+ result["error"] = None
+ result["mark_time"] = mark_time
+ result["total_time"] = total_time
- gt_frames = get_video_length(os.path.join(gt_dir, data_path, 'video.mp4'))
- sample_frames = get_video_length(os.path.join(test_dir, data_path, 'video.mp4'))
- tqdm.write(f"{prefix}: gt_frames={gt_frames}, sample={sample_frames}, total_time={total_time}, mark_time={mark_time}")
- result['sample_frames'] = sample_frames
+ gt_frames = get_video_length(os.path.join(gt_dir, data_path, "video.mp4"))
+ sample_frames = get_video_length(os.path.join(test_dir, data_path, "video.mp4"))
+ tqdm.write(
+ f"{prefix}: gt_frames={gt_frames}, sample={sample_frames}, total_time={total_time}, mark_time={mark_time}"
+ )
+ result["sample_frames"] = sample_frames
if sample_frames != total_time - mark_time:
real_time = min(total_time - mark_time, sample_frames)
- else: real_time = sample_frames
+ else:
+ real_time = sample_frames
if video_max_time is not None:
real_time = min(real_time, video_max_time)
- gt_reader = VideoStreamReader(os.path.join(gt_dir, data_path, 'video.mp4'), start_frame=mark_time, total_frames=mark_time + real_time)
- sample_reader = VideoStreamReader(os.path.join(test_dir, data_path, 'video.mp4'), start_frame=0, total_frames=real_time)
+ gt_reader = VideoStreamReader(
+ os.path.join(gt_dir, data_path, "video.mp4"),
+ start_frame=mark_time,
+ total_frames=mark_time + real_time,
+ )
+ sample_reader = VideoStreamReader(
+ os.path.join(test_dir, data_path, "video.mp4"), start_frame=0, total_frames=real_time
+ )
while True:
tqdm.write(f"{prefix}: [1/5] Reading videos...")
@@ -147,42 +179,50 @@ def compute_metrics_single_gpu(task_queue, result_list, gt_root, test_root, dino
if is_ended or gt_imgs is None or sample_imgs is None:
break
- if 'lcm' in requested_metrics:
+ if "lcm" in requested_metrics:
tqdm.write(f"{prefix}: [2/5] Computing LCM metrics (MSE/PSNR/SSIM/LPIPS)...")
- lcm = lcm_metric(sample_imgs, gt_imgs, lpips_metric, ssim_metric, psnr_metric, process_batch_size, device)
- result['lcm'] = merge_lcm_results(result.get('lcm'), lcm)
+ lcm = lcm_metric(
+ sample_imgs, gt_imgs, lpips_metric, ssim_metric, psnr_metric, process_batch_size, device
+ )
+ result["lcm"] = merge_lcm_results(result.get("lcm"), lcm)
- if 'visual' in requested_metrics:
+ if "visual" in requested_metrics:
tqdm.write(f"{prefix}: [3/5] Computing visual quality metrics...")
- vq = visual_quality_metric(sample_imgs, imaging_model, aesthetic_model, clip_model, process_batch_size, device)
- result['visual_quality'] = merge_visual_results(result.get('visual_quality'), vq)
+ vq = visual_quality_metric(
+ sample_imgs, imaging_model, aesthetic_model, clip_model, process_batch_size, device
+ )
+ result["visual_quality"] = merge_visual_results(result.get("visual_quality"), vq)
- if 'dino' in requested_metrics:
+ if "dino" in requested_metrics:
tqdm.write(f"{prefix}: [4/5] Computing dino metrics...")
- dino = dino_mse_metric(sample_imgs, gt_imgs, dino_model, dino_processor, device, process_batch_size)
- result['dino'] = merge_dino_results(result.get('dino'), dino)
+ dino = dino_mse_metric(
+ sample_imgs, gt_imgs, dino_model, dino_processor, device, process_batch_size
+ )
+ result["dino"] = merge_dino_results(result.get("dino"), dino)
del gt_imgs, sample_imgs
del gt_reader, sample_reader
torch.cuda.empty_cache()
- if 'action' in requested_metrics:
+ if "action" in requested_metrics:
tqdm.write(f"{prefix}: [4/5] Computing action accuracy (ViPE)...")
# 计算action accuracy
- actions = extract_actions_from_json(os.path.join(gt_dir, data_path, 'action.json'), mark_time, 97)
+ actions = extract_actions_from_json(
+ os.path.join(gt_dir, data_path, "action.json"), mark_time, 97
+ )
action = action_accuracy_metric(
- os.path.join(test_dir, data_path, 'video.mp4'),
- os.path.join(gt_dir, data_path, 'video.mp4'),
+ os.path.join(test_dir, data_path, "video.mp4"),
+ os.path.join(gt_dir, data_path, "video.mp4"),
mark_time,
actions,
- max_frames = 97,
- gt_data_dir = os.path.join(gt_dir, data_path),
+ max_frames=97,
+ gt_data_dir=os.path.join(gt_dir, data_path),
verbose_prefix=f" {prefix}",
- gpu_id=gpu_id
+ gpu_id=gpu_id,
)
if action is not None:
- result['action'] = action
+ result["action"] = action
tqdm.write(f"{prefix}: Finish Task!")
@@ -192,12 +232,16 @@ def compute_metrics_single_gpu(task_queue, result_list, gt_root, test_root, dino
stop_event.set()
raise
except Exception as e:
- result['error'] = str(e)
+ result["error"] = str(e)
if retry_count < 3:
- tqdm.write(f"{prefix}: Error processing {data_path} on GPU{gpu_id}: {e}, retrying ({retry_count + 1}/3)")
+ tqdm.write(
+ f"{prefix}: Error processing {data_path} on GPU{gpu_id}: {e}, retrying ({retry_count + 1}/3)"
+ )
task_queue.put(Task(data, retry_count + 1))
else:
- tqdm.write(f"{prefix}: Error processing {data_path} on GPU{gpu_id}: {e}, max retries exceeded, giving up")
+ tqdm.write(
+ f"{prefix}: Error processing {data_path} on GPU{gpu_id}: {e}, max retries exceeded, giving up"
+ )
result_list.append(result)
continue
@@ -206,42 +250,60 @@ def compute_metrics_single_gpu(task_queue, result_list, gt_root, test_root, dino
finally:
tqdm.write(f"GPU{gpu_id}: Cleaning up...")
- if 'lcm' in requested_metrics or 'gsc' in requested_metrics:
+ if "lcm" in requested_metrics or "gsc" in requested_metrics:
del lpips_metric, ssim_metric, psnr_metric
- if 'visual' in requested_metrics:
+ if "visual" in requested_metrics:
del imaging_model, aesthetic_model, clip_model
torch.cuda.empty_cache()
-def compute_metrics(gt_root, test_root, dino_path, output_path, requested_metrics=['lcm', 'visual', 'dino', 'action'],
- video_max_time=100, process_batch_size=10, num_gpus=1):
- result_dict = {'data':[], 'video_max_time':video_max_time}
- mp.set_start_method('spawn', force=True)
+def compute_metrics(
+ gt_root,
+ test_root,
+ dino_path,
+ output_path,
+ requested_metrics=["lcm", "visual", "dino", "action"],
+ video_max_time=100,
+ process_batch_size=10,
+ num_gpus=1,
+):
+ result_dict = {"data": [], "video_max_time": video_max_time}
+
+ mp.set_start_method("spawn", force=True)
all_data = []
- for perspective in ['1st_data', '3rd_data']:
- for test_type in ['mem_test', 'action_space_test', 'mirror_test']:
- if test_type == 'mirror_test':
- if 'gsc' in requested_metrics:
+ for perspective in ["1st_data", "3rd_data"]:
+ for test_type in ["mem_test", "action_space_test", "mirror_test"]:
+ if test_type == "mirror_test":
+ if "gsc" in requested_metrics:
test_dir = os.path.join(test_root, perspective, test_type)
- all_data += [{'path': d, 'perspective': perspective, 'test_type': test_type }
- for d in os.listdir(test_dir)]
+ all_data += [
+ {"path": d, "perspective": perspective, "test_type": test_type} for d in os.listdir(test_dir)
+ ]
else:
- if 'lcm' in requested_metrics or 'visual' in requested_metrics or 'dino' in requested_metrics or 'action' in requested_metrics:
- gt_dir = os.path.join(gt_root, perspective, 'test', test_type)
+ if (
+ "lcm" in requested_metrics
+ or "visual" in requested_metrics
+ or "dino" in requested_metrics
+ or "action" in requested_metrics
+ ):
+ gt_dir = os.path.join(gt_root, perspective, "test", test_type)
test_dir = os.path.join(test_root, perspective, test_type)
- all_data += [{'path': d, 'perspective': perspective, 'test_type': test_type}
- for d in os.listdir(test_dir) if os.path.exists(os.path.join(gt_dir, d))]
+ all_data += [
+ {"path": d, "perspective": perspective, "test_type": test_type}
+ for d in os.listdir(test_dir)
+ if os.path.exists(os.path.join(gt_dir, d))
+ ]
if len(all_data) == 0:
- tqdm.write(f"No data found!")
+ tqdm.write("No data found!")
return
total_tasks = len(all_data)
- tqdm.write(f"\n{'='*60}")
+ tqdm.write(f"\n{'=' * 60}")
tqdm.write(f"Total data: {total_tasks}, Using {num_gpus} GPU(s) with task queue")
- tqdm.write(f"{'='*60}\n")
+ tqdm.write(f"{'=' * 60}\n")
ensure_all_models_downloaded()
@@ -263,8 +325,8 @@ def monitor_progress():
if current_count > last_count:
pbar.update(current_count - last_count)
last_count = current_count
- with open(output_path, 'w') as f:
- result_dict['data'] = list(result_list)
+ with open(output_path, "w") as f:
+ result_dict["data"] = list(result_list)
json.dump(result_dict, f, indent=2)
if last_count >= total_tasks:
break
@@ -281,15 +343,26 @@ def monitor_progress():
try:
with mp.get_context("spawn").Pool(processes=num_gpus) as pool:
worker_args = [
- (task_queue, result_list, gt_root, test_root, dino_path,
- requested_metrics, video_max_time, process_batch_size, f'cuda:{i}', i, stop_event)
+ (
+ task_queue,
+ result_list,
+ gt_root,
+ test_root,
+ dino_path,
+ requested_metrics,
+ video_max_time,
+ process_batch_size,
+ f"cuda:{i}",
+ i,
+ stop_event,
+ )
for i in range(num_gpus)
]
pool.starmap(compute_metrics_single_gpu, worker_args)
monitor_thread.join()
- result_dict['data'] = list(result_list)
+ result_dict["data"] = list(result_list)
except KeyboardInterrupt:
tqdm.write("\n\nInterrupted by user! Merging partial results...")
@@ -297,35 +370,47 @@ def monitor_progress():
pool.terminate()
pool.join()
monitor_thread.join(timeout=2)
- result_dict['data'] = list(result_list)
+ result_dict["data"] = list(result_list)
tqdm.write(f" Merged {len(result_dict['data'])} results from shared list")
return result_dict
-if __name__ == '__main__':
+
+if __name__ == "__main__":
import argparse
+
warnings.filterwarnings("ignore", message=".*pretrained.*")
warnings.filterwarnings("ignore", message=".*Weights.*")
warnings.filterwarnings("ignore", message=".*video.*deprecated.*")
warnings.filterwarnings("ignore", message=".*timm.models.layers.*")
warnings.filterwarnings("ignore", message=".*TRANSFORMERS_CACHE.*")
- parser = argparse.ArgumentParser(description='Compute video metrics with multi-GPU support')
- parser.add_argument('--gt_root', type=str, required=True, help='Ground truth data root directory')
- parser.add_argument('--test_root', type=str, required=True, help='Test data root directory')
- parser.add_argument('--dino_path', type=str, default='./dinov3_vitb16',
- help='dinov3 weight directory, for example ./dinov3_vitb16')
- parser.add_argument('--num_gpus', type=int, default=1, help='Number of GPUs to use (default: 1)')
- parser.add_argument('--video_max_time', type=int, default=None, help='Maximum video frames (default: None = use all frames)')
- parser.add_argument('--output', type=str, default=None, help='Output JSON file path')
- parser.add_argument('--metrics', type=str, default='lcm,visual,dino,action,gsc', help='Requested metrics to compute, comma separated (e.g. dino,visual)')
+ parser = argparse.ArgumentParser(description="Compute video metrics with multi-GPU support")
+ parser.add_argument("--gt_root", type=str, required=True, help="Ground truth data root directory")
+ parser.add_argument("--test_root", type=str, required=True, help="Test data root directory")
+ parser.add_argument(
+ "--dino_path", type=str, default="./dinov3_vitb16", help="dinov3 weight directory, for example ./dinov3_vitb16"
+ )
+ parser.add_argument("--num_gpus", type=int, default=1, help="Number of GPUs to use (default: 1)")
+ parser.add_argument(
+ "--video_max_time", type=int, default=None, help="Maximum video frames (default: None = use all frames)"
+ )
+ parser.add_argument("--output", type=str, default=None, help="Output JSON file path")
+ parser.add_argument(
+ "--metrics",
+ type=str,
+ default="lcm,visual,dino,action,gsc",
+ help="Requested metrics to compute, comma separated (e.g. dino,visual)",
+ )
args = parser.parse_args()
# 检查GPU数量
available_gpus = torch.cuda.device_count()
if args.num_gpus > available_gpus:
- tqdm.write(f"Warning: Requested {args.num_gpus} GPUs but only {available_gpus} available. Using {available_gpus} GPUs.")
+ tqdm.write(
+ f"Warning: Requested {args.num_gpus} GPUs but only {available_gpus} available. Using {available_gpus} GPUs."
+ )
args.num_gpus = available_gpus
# 准备输出路径
@@ -333,7 +418,8 @@ def monitor_progress():
output_path = args.output
else:
from datetime import datetime
- output_path = f'result_{os.path.basename(args.test_root)}_{datetime.now().strftime("%Y-%m-%d-%H:%M:%S")}.json'
+
+ output_path = f"result_{os.path.basename(args.test_root)}_{datetime.now().strftime('%Y-%m-%d-%H:%M:%S')}.json"
tqdm.write(f"Starting computation with {args.num_gpus} GPU(s)...")
tqdm.write(f"Output will be saved to: {output_path}")
@@ -345,14 +431,14 @@ def monitor_progress():
args.test_root,
args.dino_path,
output_path,
- requested_metrics=[m.strip() for m in args.metrics.split(',')],
+ requested_metrics=[m.strip() for m in args.metrics.split(",")],
video_max_time=args.video_max_time,
- num_gpus=args.num_gpus
+ num_gpus=args.num_gpus,
)
except KeyboardInterrupt:
- tqdm.write("\n\n" + "="*60)
+ tqdm.write("\n\n" + "=" * 60)
tqdm.write("INTERRUPTED BY USER (Ctrl+C)")
- tqdm.write("="*60)
+ tqdm.write("=" * 60)
if video_results is None:
tqdm.write("No results to save (computation was interrupted too early)")
@@ -362,9 +448,8 @@ def monitor_progress():
tqdm.write(f"{len(video_results['data'])} videos are processed")
tqdm.write(f"checking output path {output_path}:")
if os.path.exists(output_path):
- with open(output_path, 'r') as f:
+ with open(output_path, "r") as f:
saved_results = json.load(f)
tqdm.write(f"{len(saved_results['data'])} videos are saved")
else:
tqdm.write("\nNo results to save.")
-
\ No newline at end of file
diff --git a/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/video_quality/imaging_quality.py b/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/video_quality/imaging_quality.py
index 69acd582e..643c1866f 100644
--- a/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/video_quality/imaging_quality.py
+++ b/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/video_quality/imaging_quality.py
@@ -1,10 +1,11 @@
-"""Imaging quality metric — MUSIQ (via pyiqa)."""
+"""Imaging quality metric — the official MUSIQ-KonIQ variant via PyIQA."""
+
import numpy as np
import torch
import torchvision.transforms as T
from ..base import BaseMetric
-from ..weight_utils import setup_torch_hub_dir, get_weights_dir
+from ..weight_utils import wbench_asset_path
def _vbench_transform(img_tensor, max_edge=512):
@@ -20,13 +21,13 @@ def _vbench_transform(img_tensor, max_edge=512):
class ImagingQualityMetric(BaseMetric):
def __init__(self, device="cuda"):
super().__init__(device)
- setup_torch_hub_dir()
- import os
import pyiqa
- import pyiqa.utils.download_util as _dl
- # Keep pyiqa aligned with the WBench MUSIQ asset location.
- _dl.DEFAULT_CACHE_DIR = get_weights_dir("pyiqa")
- self.model = pyiqa.create_metric('musiq', device=self.device)
+
+ self.model = pyiqa.create_metric(
+ "musiq",
+ device=self.device,
+ pretrained_model_path=str(wbench_asset_path("wbench_musiq_koniq_checkpoint")),
+ )
@property
def name(self):
diff --git a/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/weight_utils.py b/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/weight_utils.py
index 97469dc0c..1535d2bbd 100644
--- a/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/weight_utils.py
+++ b/worldfoundry/evaluation/tasks/execution/runners/wbench/runtime/wbench/src/metrics/weight_utils.py
@@ -13,7 +13,7 @@
_DIR_ASSET_BY_SUBDIR = {
"clip": "wbench_clip_vit_b32_checkpoint",
- "pyiqa": "wbench_musiq_spaq_checkpoint",
+ "pyiqa": "wbench_musiq_koniq_checkpoint",
}
_LEGACY_ASSET_RELATIVE_PATHS = {
@@ -23,9 +23,9 @@
"aesthetic/sa_0_4_vit_l_14_linear.pth",
"aesthetic_model/emb_reader/sa_0_4_vit_l_14_linear.pth",
),
- "wbench_musiq_spaq_checkpoint": (
- "pyiqa/musiq_spaq_ckpt-358bb6af.pth",
- "pyiqa_model/musiq_spaq_ckpt-358bb6af.pth",
+ "wbench_musiq_koniq_checkpoint": (
+ "pyiqa/musiq_koniq_ckpt-e95806b9.pth",
+ "pyiqa_model/musiq_koniq_ckpt-e95806b9.pth",
),
"wbench_clip_vit_b16_model_dir": (
"clip-vit-base-patch16",
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/run_worldmembench_official_runner.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/run_worldmembench_official_runner.py
new file mode 100644
index 000000000..26a6e863c
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/run_worldmembench_official_runner.py
@@ -0,0 +1,204 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Run the in-tree WorldMemBench evaluator or import official per-model results."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+from typing import Any
+
+from worldfoundry.evaluation.tasks.execution.framework.io import write_json, write_jsonl
+from worldfoundry.evaluation.tasks.execution.framework.official_runner import (
+ BenchRunnerConfig,
+ build_scorecard,
+ write_failed_scorecard,
+)
+from worldfoundry.evaluation.tasks.execution.framework.runner_common import resolve_env_path as _env_path
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.worldmembench_metrics import (
+ METRIC_SPECS,
+ load_official_results,
+)
+
+CONFIG = BenchRunnerConfig(
+ benchmark_id="worldmembench",
+ display_name="WorldMemBench",
+ root_env="",
+ results_path_env="WORLDFOUNDRY_WORLDMEMBENCH_RESULTS_PATH",
+ default_repo_subdir="",
+ metric_order=tuple(METRIC_SPECS),
+ metric_specs={
+ name: {"name": name.replace("_", " ").title(), "group": name.split("_")[0], "higher_is_better": higher}
+ for name, (_, higher) in METRIC_SPECS.items()
+ },
+ metric_aliases={},
+ average_metric_id=None,
+)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Expose the standard benchmark runner flags and metric asset configuration."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--benchmark-id", choices=["worldmembench"], default="worldmembench")
+ parser.add_argument(
+ "--official-results-path", "--results-path", type=Path, default=_env_path(CONFIG.results_path_env)
+ )
+ parser.add_argument(
+ "--generated-video-dir",
+ "--generated-artifact-dir",
+ "--input-path",
+ type=Path,
+ default=_env_path("WORLDFOUNDRY_GENERATED_ARTIFACT_DIR"),
+ help="Official video tree, video file, or JSONL input manifest",
+ )
+ parser.add_argument("--output-dir", type=Path, required=True)
+ parser.add_argument("--run-official", action="store_true")
+ parser.add_argument("--python", default=os.environ.get("WORLDFOUNDRY_WORLDMEMBENCH_PYTHON", sys.executable))
+ parser.add_argument("--config", type=Path, default=_env_path("WORLDFOUNDRY_WORLDMEMBENCH_CONFIG"))
+ parser.add_argument("--weights-dir", type=Path, default=_env_path("WORLDFOUNDRY_WORLDMEMBENCH_WEIGHTS_DIR"))
+ parser.add_argument("--device", default="cuda")
+ parser.add_argument("--gpus", help="Official multi-GPU process sharding, e.g. 0,1")
+ parser.add_argument("--model-id", help="Select one upstream model group; required for multi-model imports")
+ parser.add_argument(
+ "--metric",
+ "--metrics",
+ action="append",
+ default=[],
+ help="Official metric IDs; repeat the flag or separate IDs with commas",
+ )
+ parser.add_argument("--expected-revisits", type=int, choices=[1, 2, 3])
+ parser.add_argument(
+ "--timeout", type=float, default=float(os.environ.get("WORLDFOUNDRY_BENCHMARK_TIMEOUT", "7200"))
+ )
+ parser.add_argument("--json", action="store_true")
+ return parser
+
+
+def build_official_command(args: argparse.Namespace, results: Path, metrics: tuple[str, ...]) -> list[str]:
+ """Run the in-tree official protocol, including its multi-GPU sharding."""
+ if args.generated_video_dir is None or not args.generated_video_dir.exists():
+ raise ValueError("--run-official requires an existing --generated-video-dir or JSONL --input-path")
+ command = [
+ args.python,
+ "-m",
+ "worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime",
+ "eval",
+ str(args.generated_video_dir.resolve()),
+ "--output",
+ str(results),
+ ]
+ if metrics:
+ command.extend(["--metrics", *metrics])
+ if args.model_id and args.model_id != "all":
+ command.extend(["--models", args.model_id])
+ if args.gpus:
+ command.extend(["--gpus", args.gpus])
+ else:
+ command.extend(["--device", args.device])
+ for flag, value in [("--config", args.config), ("--weights-dir", args.weights_dir)]:
+ if value is not None:
+ command.extend([flag, str(value.expanduser().resolve())])
+ if args.expected_revisits is not None:
+ command.extend(["--expected-revisits", str(args.expected_revisits)])
+ return command
+
+
+def run(args: argparse.Namespace) -> tuple[dict[str, Any], int]:
+ """Write standard scorecard artifacts, retaining upstream coverage and failures."""
+ output = args.output_dir.expanduser().resolve()
+ output.mkdir(parents=True, exist_ok=True)
+ command: list[str] | None = None
+ started = time.monotonic()
+ try:
+ metrics = tuple(
+ dict.fromkeys(name.strip() for value in args.metric for name in value.split(",") if name.strip())
+ )
+ if set(metrics) - METRIC_SPECS.keys():
+ raise ValueError("Unknown WorldMemBench metric: " + ", ".join(sorted(set(metrics) - METRIC_SPECS.keys())))
+ results = args.official_results_path
+ returncode = 0
+ if args.run_official:
+ if results is not None:
+ raise ValueError("Use --run-official or --official-results-path, not both")
+ results = output / "official"
+ if results.exists():
+ raise ValueError(f"Use a fresh output directory; official results already exist: {results}")
+ command = build_official_command(args, results, metrics)
+ environment = dict(os.environ)
+ with (
+ (output / "runtime_stdout.log").open("w", encoding="utf-8") as stdout,
+ (output / "runtime_stderr.log").open("w", encoding="utf-8") as stderr,
+ ):
+ completed = subprocess.run(
+ command,
+ env=environment,
+ stdout=stdout,
+ stderr=stderr,
+ timeout=args.timeout,
+ check=False,
+ )
+ returncode = completed.returncode
+ elif results is None:
+ raise ValueError("Pass --official-results-path or --run-official with generated videos")
+ results = results.expanduser().resolve()
+ model_id, extracted, cases = load_official_results(results, model_id=args.model_id, metrics=metrics)
+ evaluation_failed = any(
+ row["coverage"]["status_counts"].get(status, 0)
+ for row in extracted.values()
+ for status in ("eval_failed", "data_invalid")
+ )
+ if evaluation_failed or not any(row["raw_score"] is not None for row in extracted.values()):
+ returncode = returncode or 1
+ scorecard = build_scorecard(
+ config=CONFIG,
+ output_dir=output,
+ results_path=results,
+ extracted=extracted,
+ command=command,
+ duration_seconds=time.monotonic() - started,
+ returncode=returncode,
+ generated_video_dir=args.generated_video_dir,
+ run_status_override="failed" if returncode else None,
+ )
+ scorecard["dataset"]["model_id"] = model_id
+ scorecard["evaluation"]["kind"] = "worldmembench_official"
+ scorecard["evaluation"]["available"] = returncode == 0
+ if returncode:
+ scorecard["metrics"]["leaderboard"] = {}
+ scorecard["evaluation"]["leaderboard_metrics"] = {}
+ scorecard["validation"]["upstream_case_failures"] = evaluation_failed
+ for name, item in extracted.items():
+ scorecard["metrics"]["per_metric"][name].update(
+ {key: item[key] for key in ("coverage", "official_scores", "official_groups")}
+ )
+ write_jsonl(output / "raw_metric_table.jsonl", list(scorecard["metrics"]["per_metric"].values()))
+ write_jsonl(output / "per_sample_scores.jsonl", cases)
+ write_json(output / "scorecard.json", scorecard)
+ return scorecard, 0 if returncode == 0 else 1
+ except (ValueError, OSError, subprocess.SubprocessError) as error:
+ scorecard = write_failed_scorecard(
+ benchmark_id=CONFIG.benchmark_id,
+ display_name=CONFIG.display_name,
+ output_dir=output,
+ reason=str(error),
+ command=command,
+ )
+ return scorecard, 1
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Run the benchmark CLI without importing metric models in this process."""
+ args = build_parser().parse_args(argv)
+ scorecard, returncode = run(args)
+ print(json.dumps(scorecard, ensure_ascii=False) if args.json else str(args.output_dir / "scorecard.json"))
+ return returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/LICENSE b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/LICENSE
new file mode 100644
index 000000000..dce4b5ccf
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 WorldMemBench
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/__init__.py
new file mode 100644
index 000000000..e330cec41
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""WorldMemBench."""
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/__main__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/__main__.py
new file mode 100644
index 000000000..9b3c4944c
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/__main__.py
@@ -0,0 +1,6 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.cli import main
+
+raise SystemExit(main())
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/cli.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/cli.py
new file mode 100644
index 000000000..7ce38edb4
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/cli.py
@@ -0,0 +1,106 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""WorldMemBench command-line entry point."""
+
+import argparse
+import sys
+from pathlib import Path
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.data import load_samples, write_manifest
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.evaluate import evaluate
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics import registry
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.paths import DEFAULT_WEIGHTS_DIR
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(prog="worldmembench", description="WorldMemBench evaluation")
+ commands = parser.add_subparsers(dest="command", required=True)
+ commands.add_parser("list-metrics", help="List available metrics")
+ run = commands.add_parser("eval", help="Evaluate a video, video directory, or JSONL manifest")
+ run.add_argument("input", help="Video file, directory, or JSONL manifest")
+ run.add_argument("--metrics", nargs="+", help="Metrics to run (default: all available)")
+ run.add_argument("--output", help="Result directory (default: eval_results/)")
+ devices = run.add_mutually_exclusive_group()
+ devices.add_argument("--device", help="Single device, e.g. cuda:0 or cpu (default: cuda)")
+ devices.add_argument("--gpus", help="Comma-separated visible GPU indices, e.g. 0,1; one process per GPU")
+ run.add_argument("--weights-dir", help=f"Weight directory (default: {DEFAULT_WEIGHTS_DIR})")
+ run.add_argument("--config", help="YAML settings for checkpoints, metric batch sizes, and the DA3 runtime")
+ vlm = run.add_argument_group("Dynamic Memory VLM")
+ vlm.add_argument(
+ "--vlm-backend", choices=("local", "api"), help="Self-hosted vLLM (default: local) or external API"
+ )
+ vlm.add_argument("--vlm-base-url", help="VLM endpoint (default: http://127.0.0.1:8001/v1)")
+ vlm.add_argument("--vlm-model", help="Served model ID (default: Qwen3.8-27B)")
+ vlm.add_argument(
+ "--vlm-api-key-env",
+ help="API key environment variable name, never the key itself (default: WORLDMEMBENCH_API_KEY)",
+ )
+ run.add_argument(
+ "--expected-revisits",
+ type=int,
+ choices=(1, 2, 3),
+ help="Fixed Human/Object revisit protocol, e.g. 2 for LRLR; omitted: preserve manifest K, otherwise detect returns automatically",
+ )
+ manifest = commands.add_parser("manifest", help="Create an input manifest without evaluating videos")
+ manifest.add_argument("input", help="Video directory, file, or existing manifest")
+ manifest.add_argument("--output", required=True, help="New output JSONL manifest")
+ for command in (run, manifest):
+ command.add_argument("--models", nargs="+", help="Filter generation model IDs")
+ command.add_argument("--categories", nargs="+", help="Filter categories, e.g. human object environment")
+ command.add_argument("--trajectories", nargs="+", help="Filter trajectory IDs")
+ command.add_argument("--cases", nargs="+", help="Filter case IDs, e.g. case_002")
+ args = parser.parse_args(argv)
+ if args.command == "list-metrics":
+ print("\n".join(registry.names()) or "WorldMemBench has no metrics yet.")
+ return 0
+ try:
+ filters = dict(models=args.models, categories=args.categories, trajectories=args.trajectories, cases=args.cases)
+ if args.command == "manifest":
+ samples = load_samples(args.input, **filters)
+ output = write_manifest(samples, args.output)
+ print(f"Saved {len(samples)} samples to {output}")
+ return 0
+ config = {}
+ if args.config:
+ try:
+ import yaml
+ except ImportError as error:
+ raise RuntimeError("--config requires PyYAML") from error
+ try:
+ config = yaml.safe_load(Path(args.config).read_text(encoding="utf-8")) or {}
+ except yaml.YAMLError as error:
+ raise ValueError(f"invalid YAML config: {error}") from error
+ if not isinstance(config, dict):
+ raise ValueError("config must be a YAML mapping")
+ vlm_options = {
+ key: value
+ for key, value in {
+ "backend": args.vlm_backend,
+ "base_url": args.vlm_base_url,
+ "model": args.vlm_model,
+ "api_key_env": args.vlm_api_key_env,
+ }.items()
+ if value is not None
+ }
+ if vlm_options:
+ config.setdefault("metrics", {}).setdefault("dynamic_memory", {}).update(vlm_options)
+ output, success = evaluate(
+ args.input,
+ metrics=args.metrics,
+ output=args.output,
+ expected_revisits=args.expected_revisits,
+ **filters,
+ device="cuda" if args.gpus is not None else args.device or config.get("device", "cuda"),
+ gpus=args.gpus,
+ weights_dir=args.weights_dir or config.get("cache_dir", DEFAULT_WEIGHTS_DIR),
+ config=config,
+ command=list(sys.orig_argv) if argv is None else ["worldmembench", *argv],
+ )
+ except (ValueError, OSError, RuntimeError) as error:
+ print(f"error: {error}", file=sys.stderr)
+ return 1
+ print(f"Results saved to {output}")
+ if not success:
+ print("Some cases failed; see error fields in metrics/*/cases.jsonl.", file=sys.stderr)
+ return 0 if success else 1
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/context.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/context.py
new file mode 100644
index 000000000..418d55ddf
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/context.py
@@ -0,0 +1,60 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Per-run configuration, resource lifecycle, and generic value cache."""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable, Hashable
+from pathlib import Path
+from typing import Any, TypeVar, cast
+
+T = TypeVar("T")
+
+
+class EvaluationContext:
+ def __init__(
+ self,
+ *,
+ device: str,
+ cache_dir: str | Path,
+ config: dict[str, Any] | None = None,
+ logger: logging.Logger | None = None,
+ ) -> None:
+ self.device = device
+ self.cache_dir = Path(cache_dir).expanduser().resolve()
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
+ self.config = dict(config or {})
+ self.logger = logger or logging.getLogger("worldmembench")
+ self._resources: dict[Hashable, Any] = {}
+ self._cache: dict[Hashable, Any] = {}
+
+ def metric_config(self, metric_name: str) -> dict[str, Any]:
+ metrics = self.config.get("metrics") or {}
+ value = metrics.get(metric_name) or {}
+ if not isinstance(value, dict):
+ raise TypeError(f"config.metrics.{metric_name} must be a mapping")
+ return dict(value)
+
+ def get_resource(self, key: Hashable, factory: Callable[[], T]) -> T:
+ if key not in self._resources:
+ self._resources[key] = factory()
+ return cast(T, self._resources[key])
+
+ def get_cached(self, key: Hashable, factory: Callable[[], T]) -> T:
+ if key not in self._cache:
+ self._cache[key] = factory()
+ return cast(T, self._cache[key])
+
+ def close(self) -> None:
+ for resource in reversed(list(self._resources.values())):
+ close = getattr(resource, "close", None)
+ if callable(close):
+ close()
+ self._resources.clear()
+ self._cache.clear()
+
+ def clear_frame_cache(self) -> None:
+ """Release decoded frames after all metrics have evaluated one sample."""
+ self._cache.clear()
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/data.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/data.py
new file mode 100644
index 000000000..cee29f92a
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/data.py
@@ -0,0 +1,125 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Load videos directly, or a JSONL manifest with additional sample fields."""
+
+import json
+import os
+from pathlib import Path
+
+VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
+ANNOTATION_PATH_FIELDS = ("condition_image", "target_object_mask_first_frame")
+ARTIFACT_PATH_FIELDS = ("da3", "da3_scene", "target_poses", "vipe")
+
+
+def _video_sample(path: Path, source: Path) -> dict:
+ # Both public layouts carry unambiguous labels at a named root.
+ if (
+ len(path.parents) >= 5
+ and path.parent.name.startswith("case_")
+ and path.name == "video.mp4"
+ and path.parents[4].name in {"samples", "inference_results", "WorldMemBench"}
+ ):
+ first, second, trajectory, case_id, _ = path.parts[-5:]
+ if path.parents[4].name == "samples":
+ model, category = first, second
+ else:
+ category, model = first, second
+ return {
+ "item_id": "/".join((model, category, trajectory, case_id)),
+ "model_id": model,
+ "category": category,
+ "condition_id": trajectory,
+ "sample_id": case_id,
+ "video": str(path),
+ }
+ return {"item_id": path.relative_to(source).as_posix(), "video": str(path)}
+
+
+def load_samples(
+ source: str | Path,
+ *,
+ models: list[str] | None = None,
+ categories: list[str] | None = None,
+ trajectories: list[str] | None = None,
+ cases: list[str] | None = None,
+) -> list[dict]:
+ source = Path(source).expanduser().resolve()
+ if source.is_dir():
+ samples = [
+ _video_sample(path, source)
+ for path in sorted(source.rglob("*"))
+ if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS
+ ]
+ elif source.is_file() and source.suffix.lower() == ".jsonl":
+ samples = []
+ for number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), 1):
+ if not line.strip():
+ continue
+ try:
+ sample = json.loads(line)
+ video = (source.parent / sample["video"]).resolve()
+ if not video.is_file():
+ raise ValueError(f"video not found: {video}")
+ sample["video"] = str(video)
+ annotations = sample.get("annotations")
+ if isinstance(annotations, dict):
+ for field in ANNOTATION_PATH_FIELDS:
+ if isinstance(annotations.get(field), str) and annotations[field]:
+ annotations[field] = str((source.parent / annotations[field]).resolve())
+ artifacts = sample.get("artifacts")
+ if isinstance(artifacts, dict):
+ for field in ARTIFACT_PATH_FIELDS:
+ if isinstance(artifacts.get(field), str) and artifacts[field]:
+ artifacts[field] = str((source.parent / artifacts[field]).resolve())
+ sample.setdefault("item_id", str(number))
+ samples.append(sample)
+ except (ValueError, KeyError, TypeError) as error:
+ raise ValueError(f"{source}:{number}: {error}") from error
+ elif source.is_file() and source.suffix.lower() in VIDEO_EXTENSIONS:
+ samples = [_video_sample(source, source.parent)]
+ else:
+ raise ValueError(f"expected a video, video directory, or JSONL manifest: {source}")
+ if not samples:
+ raise ValueError(f"no samples found: {source}")
+ ids = [sample["item_id"] for sample in samples]
+ if any(not isinstance(value, str) or not value.strip() for value in ids):
+ raise ValueError("item_id must be a non-empty string")
+ if len(ids) != len(set(ids)):
+ raise ValueError("duplicate item_id in manifest")
+ for field, values in (
+ ("model_id", models),
+ ("category", categories),
+ ("condition_id", trajectories),
+ ("sample_id", cases),
+ ):
+ if values:
+ if any(not sample.get(field) for sample in samples):
+ raise ValueError(f"filter requires {field}; use the standard samples layout or a manifest")
+ samples = [sample for sample in samples if sample[field] in values]
+ if not samples:
+ raise ValueError("no samples match the requested filters")
+ return samples
+
+
+def write_manifest(samples: list[dict], output: str | Path) -> Path:
+ """Save relative media and artifact paths alongside sample annotations."""
+ output = Path(output).expanduser().resolve()
+ output.parent.mkdir(parents=True, exist_ok=True)
+ with output.open("x", encoding="utf-8") as handle:
+ for sample in samples:
+ row = {**sample, "video": os.path.relpath(sample["video"], output.parent)}
+ if isinstance(sample.get("annotations"), dict):
+ row["annotations"] = dict(sample["annotations"])
+ for field in ANNOTATION_PATH_FIELDS:
+ value = row["annotations"].get(field)
+ if isinstance(value, str) and value:
+ row["annotations"][field] = os.path.relpath(value, output.parent)
+ if isinstance(sample.get("artifacts"), dict):
+ row["artifacts"] = dict(sample["artifacts"])
+ for field in ARTIFACT_PATH_FIELDS:
+ value = row["artifacts"].get(field)
+ if isinstance(value, str) and value:
+ row["artifacts"][field] = os.path.relpath(value, output.parent)
+ handle.write(json.dumps(row, ensure_ascii=False, allow_nan=False) + "\n")
+ return output
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/errors.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/errors.py
new file mode 100644
index 000000000..893bfdc94
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/errors.py
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Structured errors mapped to WorldMemBench result statuses."""
+
+
+class WorldMemBenchError(Exception):
+ """Base class for expected WorldMemBench errors."""
+
+
+class DataInvalidError(WorldMemBenchError):
+ """The benchmark item or its required annotations are invalid."""
+
+
+class EvaluationError(WorldMemBenchError):
+ """The evaluator failed independently of the generated model output."""
+
+
+class VideoDecodeError(EvaluationError):
+ """A required video frame could not be decoded."""
+
+
+class NotApplicableError(WorldMemBenchError):
+ """The requested metric is not applicable to the item."""
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/evaluate.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/evaluate.py
new file mode 100644
index 000000000..256d43cb6
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/evaluate.py
@@ -0,0 +1,121 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Evaluation orchestration with per-case errors and complete result reporting."""
+
+from datetime import datetime
+from pathlib import Path
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.data import load_samples, write_manifest
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.execution import (
+ evaluate_parallel,
+ evaluate_shard,
+ merge_shards,
+ parse_gpus,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics import MetricRegistry, registry
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.paths import DEFAULT_WEIGHTS_DIR
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import ResultStatus, ResultWriter
+
+
+def evaluate(
+ source: str | Path,
+ *,
+ metrics: list[str] | None = None,
+ models: list[str] | None = None,
+ categories: list[str] | None = None,
+ trajectories: list[str] | None = None,
+ cases: list[str] | None = None,
+ expected_revisits: int | None = None,
+ output: str | Path | None = None,
+ device: str = "cuda",
+ gpus: str | None = None,
+ weights_dir: str | Path = DEFAULT_WEIGHTS_DIR,
+ config: dict | None = None,
+ metric_registry: MetricRegistry = registry,
+ command: list[str] | None = None,
+) -> tuple[Path, bool]:
+ names = metric_registry.names() if metrics is None else metrics
+ if not names:
+ raise ValueError("No metrics selected. Choose an available WorldMemBench metric.")
+ if len(names) != len(set(names)):
+ raise ValueError("each metric may be selected only once")
+ selected = [metric_registry.get(name) for name in names]
+ gpu_ids = parse_gpus(gpus) if gpus is not None else None
+ if gpu_ids is not None and device != "cuda":
+ raise ValueError("use --device for one device or --gpus for GPU selection, not both")
+ if gpu_ids is not None:
+ import torch
+
+ if not torch.cuda.is_available():
+ raise RuntimeError("--gpus requires available CUDA devices")
+ if max(gpu_ids) >= torch.cuda.device_count():
+ raise ValueError(f"GPU index out of range; {torch.cuda.device_count()} device(s) visible")
+ samples = load_samples(source, models=models, categories=categories, trajectories=trajectories, cases=cases)
+ output = Path(output) if output is not None else Path("eval_results") / datetime.now().strftime("%Y%m%d-%H%M%S")
+ if output.exists():
+ raise FileExistsError(f"output already exists; choose another --output: {output}")
+ writer = ResultWriter(output)
+ writer.write_command(command)
+ if {"human_identity", "human_appearance"}.intersection(names):
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.human.preparation import (
+ prepare_for_evaluation,
+ )
+
+ preparation_context = EvaluationContext(
+ device=f"cuda:{gpu_ids[0]}" if gpu_ids is not None else device,
+ cache_dir=weights_dir,
+ config=config,
+ )
+ try:
+ prepare_for_evaluation(
+ samples,
+ preparation_context,
+ expected_revisits=expected_revisits,
+ weight_metric="human_identity" if "human_identity" in names else "human_appearance",
+ )
+ finally:
+ preparation_context.close()
+ if {"object_appearance", "object_geometry"}.intersection(names):
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.object.preparation import (
+ prepare_for_evaluation,
+ )
+
+ prepare_for_evaluation(samples, expected_revisits=expected_revisits)
+ if {"environment_visible_memory", "environment_depth_fidelity", "environment_scene_reconstruction"}.intersection(
+ names
+ ):
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.environment.preparation import (
+ prepare_for_evaluation,
+ )
+
+ prepare_for_evaluation(samples)
+ if "dynamic_memory" in names:
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.dynamic.preparation import (
+ prepare_for_evaluation,
+ )
+
+ prepare_for_evaluation(samples)
+ if {"camera_rotation", "camera_translation"}.intersection(names):
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.camera.preparation import (
+ prepare_for_evaluation,
+ )
+
+ prepare_for_evaluation(samples, required=set(names) <= {"camera_rotation", "camera_translation"})
+ write_manifest(samples, output / "manifest.jsonl")
+ if gpu_ids is None:
+ shards = [evaluate_shard(samples, selected, device, weights_dir, config)]
+ else:
+ devices = [f"cuda:{index}" for index in gpu_ids]
+ print(f"GPU devices: {', '.join(devices)}; one video shard per active GPU.", flush=True)
+ shards = evaluate_parallel(samples, selected, devices, weights_dir, config)
+ item_results = merge_shards(shards, samples, selected)
+ success = True
+ for metric in selected:
+ items = item_results[metric.name]
+ if any(item.status in {ResultStatus.DATA_INVALID, ResultStatus.EVAL_FAILED} for item in items):
+ success = False
+ summary = metric.aggregate_summary(items)
+ writer.write_metric(metric.name, items, summary)
+ return output, success
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/execution.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/execution.py
new file mode 100644
index 000000000..88f926437
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/execution.py
@@ -0,0 +1,156 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Evaluate video shards and merge item-level results in manifest order."""
+
+import json
+import multiprocessing
+import sys
+from dataclasses import asdict
+from queue import Empty
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import ItemResult, ResultStatus
+
+
+def parse_gpus(value: str) -> list[int]:
+ parts = value.split(",")
+ if any(not part.strip().isdigit() for part in parts):
+ raise ValueError("--gpus requires comma-separated GPU indices, e.g. 0,1")
+ ids = [int(part) for part in parts]
+ if len(ids) != len(set(ids)):
+ raise ValueError("--gpus must not contain duplicate GPU indices")
+ return ids
+
+
+def evaluate_shard(samples, metrics, device, weights_dir, config):
+ context = EvaluationContext(device=device, cache_dir=weights_dir, config=config)
+ items_by_metric = {metric.name: [] for metric in metrics}
+ try:
+ names = [metric.name for metric in metrics]
+ if {
+ "environment_visible_memory",
+ "environment_depth_fidelity",
+ "environment_scene_reconstruction",
+ }.intersection(names):
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.environment.preparation import (
+ prepare_artifacts,
+ )
+
+ prepare_artifacts(samples, context, names)
+ if {"camera_rotation", "camera_translation"}.intersection(names):
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.camera.preparation import (
+ prepare_artifacts,
+ )
+
+ prepare_artifacts(samples, context, names)
+ for index, sample in enumerate(samples, 1):
+ identity = {
+ key: sample.get(key) for key in ("item_id", "model_id", "category", "sample_id", "condition_id")
+ }
+ for metric in metrics:
+ try:
+ units = metric.evaluate(sample, context)
+ if not units:
+ raise ValueError("metric returned no unit results")
+ item = metric.aggregate_item(sample, units)
+ item.status = ResultStatus(item.status)
+ for key, value in identity.items():
+ setattr(item, key, value)
+ item.num_units = len(units)
+ json.dumps(asdict(item), allow_nan=False)
+ except Exception as error:
+ item = ItemResult(
+ **identity,
+ status=(
+ ResultStatus.DATA_INVALID
+ if isinstance(error, DataInvalidError)
+ else ResultStatus.EVAL_FAILED
+ ),
+ error=f"{type(error).__name__}: {error}",
+ )
+ if item.status in {ResultStatus.DATA_INVALID, ResultStatus.EVAL_FAILED}:
+ item.scores = {}
+ print(
+ f"[{device}] {metric.name} / {sample['item_id']}: {item.status.value}: {item.error}",
+ file=sys.stderr,
+ flush=True,
+ )
+ items_by_metric[metric.name].append(item)
+ context.clear_frame_cache()
+ print(f"[{device}] {index}/{len(samples)} {sample['item_id']}", flush=True)
+ return items_by_metric
+ finally:
+ context.close()
+
+
+def _worker(samples, metrics, device, weights_dir, config):
+ if device.startswith("cuda"):
+ import torch
+
+ torch.cuda.set_device(device)
+ torch.set_num_threads(4)
+ return evaluate_shard(samples, metrics, device, weights_dir, config)
+
+
+def evaluate_parallel(samples, metrics, devices, weights_dir, config):
+ """Spawn one independent worker per active device; never fork CUDA state."""
+ active = devices[: len(samples)]
+ context = multiprocessing.get_context("spawn")
+ queue = context.Queue()
+ processes = []
+ received = {}
+ try:
+ for index, device in enumerate(active):
+ process = context.Process(
+ target=_report_worker,
+ args=(queue, index, samples[index :: len(active)], metrics, device, weights_dir, config),
+ )
+ process.start()
+ processes.append(process)
+ while len(received) < len(processes):
+ try:
+ index, result, error = queue.get(timeout=0.2)
+ except Empty:
+ for index, process in enumerate(processes):
+ if index not in received and process.exitcode is not None and process.exitcode != 0:
+ raise RuntimeError(f"worker {active[index]} exited with code {process.exitcode}")
+ continue
+ if error:
+ raise RuntimeError(error)
+ received[index] = result
+ return [received[index] for index in range(len(processes))]
+ finally:
+ # Terminate only workers created by this run when any shard fails.
+ if len(received) < len(processes):
+ for process in processes:
+ if process.is_alive():
+ process.terminate()
+ for process in processes:
+ process.join()
+ queue.close()
+ queue.join_thread()
+
+
+def _report_worker(queue, index, samples, metrics, device, weights_dir, config):
+ try:
+ result = _worker(samples, metrics, device, weights_dir, config)
+ except BaseException as error:
+ queue.put((index, None, f"{type(error).__name__}: {error}"))
+ else:
+ queue.put((index, result, None))
+
+
+def merge_shards(shards, samples, metrics):
+ order = {sample["item_id"]: index for index, sample in enumerate(samples)}
+ items = {metric.name: [] for metric in metrics}
+ for shard_items in shards:
+ for metric in metrics:
+ items[metric.name].extend(shard_items[metric.name])
+ for metric in metrics:
+ rows = items[metric.name]
+ if len(rows) != len(samples) or {row.item_id for row in rows} != set(order):
+ raise RuntimeError(f"incomplete or duplicate shard results for {metric.name}")
+ rows.sort(key=lambda row: order[row.item_id])
+ return items
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/__init__.py
new file mode 100644
index 000000000..f028df95c
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/__init__.py
@@ -0,0 +1,45 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""WorldMemBench metric interfaces and built-in registry."""
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.camera import (
+ CameraRotationMetric,
+ CameraTranslationMetric,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.dynamic import DynamicMemoryMetric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.environment import (
+ EnvironmentDepthFidelityMetric,
+ EnvironmentSceneReconstructionMetric,
+ EnvironmentVisibleMemoryMetric,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.general_video_quality import (
+ AestheticQualityMetric,
+ ImagingQualityMetric,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.human import (
+ HumanAppearanceMetric,
+ HumanIdentityMetric,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.object import (
+ ObjectAppearanceMetric,
+ ObjectGeometryMetric,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.registry import MetricRegistry
+
+registry = MetricRegistry()
+registry.register(DynamicMemoryMetric())
+registry.register(CameraRotationMetric())
+registry.register(CameraTranslationMetric())
+registry.register(ImagingQualityMetric())
+registry.register(AestheticQualityMetric())
+registry.register(HumanIdentityMetric())
+registry.register(HumanAppearanceMetric())
+registry.register(ObjectAppearanceMetric())
+registry.register(ObjectGeometryMetric())
+registry.register(EnvironmentVisibleMemoryMetric())
+registry.register(EnvironmentDepthFidelityMetric())
+registry.register(EnvironmentSceneReconstructionMetric())
+
+__all__ = ["Metric", "MetricRegistry", "registry"]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/aggregation.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/aggregation.py
new file mode 100644
index 000000000..373b08d19
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/aggregation.py
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Equal-weight case aggregation shared by video metrics."""
+
+from collections import Counter
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import (
+ ItemResult,
+ ResultStatus,
+ SummaryResult,
+)
+
+
+def aggregate_video_summary(items: list[ItemResult]) -> SummaryResult:
+ # Weight videos equally within each model and retain status/coverage counts.
+ groups: dict[str, list[ItemResult]] = {}
+ detailed_groups: dict[tuple, list[ItemResult]] = {}
+ for item in items:
+ key = str(item.model_id) if item.model_id is not None else "all"
+ groups.setdefault(key, []).append(item)
+ detailed_groups.setdefault((item.model_id, item.category, item.condition_id), []).append(item)
+
+ def aggregate_videos(group):
+ scored = [item for item in group if item.status.is_scored and bool(item.scores)]
+ score_values: dict[str, list[float]] = {}
+ for item in scored:
+ for score_key, value in item.scores.items():
+ score_values.setdefault(score_key, []).append(float(value))
+ return {
+ "scores": {name: sum(values) / len(values) for name, values in score_values.items() if values},
+ "metadata": {
+ "n_total": len(group),
+ "n_scored": len(scored),
+ **{f"n_{status.value}": sum(item.status == status for item in group) for status in ResultStatus},
+ },
+ }
+
+ return SummaryResult(
+ num_items=len(items),
+ status_counts=dict(Counter(item.status.value for item in items)),
+ results={key: aggregate_videos(group) for key, group in groups.items()},
+ groups=[
+ {"model_id": model, "category": category, "trajectory_id": trajectory, **aggregate_videos(group)}
+ for (model, category, trajectory), group in detailed_groups.items()
+ ],
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/base.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/base.py
new file mode 100644
index 000000000..c460579e4
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/base.py
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Common interface for metric evaluation and aggregation."""
+
+from abc import ABC, abstractmethod
+from collections import Counter
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import (
+ ItemResult,
+ SummaryResult,
+ UnitResult,
+)
+
+
+class Metric(ABC):
+ name: str
+ description: str = ""
+
+ @abstractmethod
+ def evaluate(self, sample: dict, context: EvaluationContext) -> list[UnitResult]:
+ """Evaluate one sample and return its fine-grained units."""
+ raise NotImplementedError
+
+ @abstractmethod
+ def aggregate_item(self, sample: dict, units: list[UnitResult]) -> ItemResult:
+ """Apply this metric's unit-to-item scoring rule."""
+ raise NotImplementedError
+
+ def aggregate_summary(self, items: list[ItemResult]) -> SummaryResult:
+ """Count statuses; metrics add their explicitly defined score aggregation."""
+ return SummaryResult(
+ num_items=len(items),
+ status_counts=dict(Counter(item.status.value for item in items)),
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/__init__.py
new file mode 100644
index 000000000..f6be86d61
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Independent Camera Following rotation and translation RPE metrics."""
+
+from .metric import CameraRotationMetric, CameraTranslationMetric
+
+__all__ = ["CameraRotationMetric", "CameraTranslationMetric"]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/metric.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/metric.py
new file mode 100644
index 000000000..5428b2a3b
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/metric.py
@@ -0,0 +1,151 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Separate rotation and translation RPE, preserving the reference estimator policy."""
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.aggregation import (
+ aggregate_video_summary,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import (
+ ItemResult,
+ ResultStatus,
+ UnitResult,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+
+def required_estimators(motion_type):
+ return ("da3",) if motion_type == "rotation" else ("da3", "vipe")
+
+
+def load_inputs(sample, reader):
+ from .pose_io import load_trajectory
+ from .rpe import rotation_path_length_deg
+ from .trajectory import canonicalize_target
+
+ motion = sample["annotations"]["camera_motion_type"]
+ try:
+ target = load_trajectory(sample["artifacts"]["target_poses"], "target")
+ target, path_length = canonicalize_target(target, motion)
+ rotation_path = rotation_path_length_deg(target) if motion in ("rotation", "mixed") else None
+ if rotation_path is not None and rotation_path <= 1e-12:
+ raise ValueError("rotation target must have a non-zero rotation path")
+ except (KeyError, ValueError) as error:
+ raise DataInvalidError(str(error)) from error
+ info = reader.probe(sample["video"])
+ if info.total_frames != target.num_poses:
+ raise EvaluationError(f"video/target frame count mismatch: {info.total_frames} != {target.num_poses}")
+ return target, path_length, rotation_path
+
+
+class _CameraMetric(Metric):
+ motion = ""
+ score_name = ""
+
+ def __init__(self, *, reader=None):
+ self.reader = reader or VideoReader()
+
+ def evaluate(self, sample, context):
+ from .pose_io import load_trajectory
+ from .rpe import compute_rpe
+ from .schemas import PoseTrajectory
+ from .trajectory import umeyama_align
+
+ if error := sample.get("camera_preparation_error"):
+ return [UnitResult("video", status=ResultStatus(error["status"]), error=error["message"])]
+ motion = (sample.get("annotations") or {}).get("camera_motion_type")
+ if motion not in (self.motion, "mixed"):
+ return [UnitResult("video", status=ResultStatus.NOT_APPLICABLE)]
+ try:
+ target, path_length, rotation_path = load_inputs(sample, self.reader)
+ except DataInvalidError:
+ raise
+ except Exception as error:
+ return [UnitResult("video", status=ResultStatus.MODEL_FAILURE, error=str(error))]
+
+ def analyze():
+ results = {}
+ for name in required_estimators(motion):
+ preparation_error = (sample.get("camera_artifact_errors") or {}).get(name)
+ try:
+ if preparation_error:
+ raise EvaluationError(preparation_error)
+ trajectory = load_trajectory(sample["artifacts"][name], name)
+ if trajectory.num_poses != target.num_poses:
+ raise EvaluationError(
+ f"{name} pose/video count mismatch: {trajectory.num_poses} != {target.num_poses}"
+ )
+ scale = None
+ if motion != "rotation":
+ alignment = umeyama_align(trajectory, target)
+ scale = alignment.scale
+ trajectory = PoseTrajectory(alignment.poses, source=name)
+ statistics = compute_rpe(target, trajectory, motion)
+ results[name] = {
+ "status": "ok",
+ "num_poses": trajectory.num_poses,
+ "alignment_scale": scale,
+ "rotation_rpe_mean_deg": statistics.rotation_mean_deg,
+ "rotation_rpe_path": statistics.rotation_path(rotation_path)
+ if rotation_path is not None
+ else None,
+ "translation_rpe_path": statistics.translation_path,
+ }
+ except Exception as error:
+ results[name] = {"status": "eval_failed", "error": f"{type(error).__name__}: {error}"}
+ return results
+
+ results = context.get_cached(("camera:rpe", sample["item_id"]), analyze)
+ metadata = {
+ "camera_motion_type": motion,
+ "num_frames": target.num_poses,
+ "num_rpe_steps": target.num_poses - 1,
+ "target_path_length": path_length,
+ "target_rotation_path_deg": rotation_path,
+ "estimators": results,
+ "direction": "lower_is_better",
+ }
+ failures = [value["error"] for value in results.values() if value["status"] != "ok"]
+ if failures:
+ return [UnitResult("video", status=ResultStatus.EVAL_FAILED, error="; ".join(failures), metadata=metadata)]
+ value = (
+ results["da3"]["rotation_rpe_path"]
+ if self.motion == "rotation"
+ else (results["da3"]["translation_rpe_path"] + results["vipe"]["translation_rpe_path"]) / 2.0
+ )
+ return [UnitResult("video", unit_type="video", scores={self.score_name: value}, metadata=metadata)]
+
+ def aggregate_item(self, sample, units):
+ unit = units[0]
+ return ItemResult(
+ sample["item_id"],
+ scores=dict(unit.scores),
+ num_units=1,
+ status=unit.status,
+ error=unit.error,
+ metadata=dict(unit.metadata),
+ )
+
+ def aggregate_summary(self, items):
+ summary = aggregate_video_summary(items)
+ summary.metadata["direction"] = "lower_is_better"
+ return summary
+
+
+class CameraRotationMetric(_CameraMetric):
+ name = "camera_rotation"
+ motion = "rotation"
+ score_name = "camera_rotation_rpe_path"
+ description = "DA3 adjacent rotation RPE normalized by target rotation path; lower is better."
+
+
+class CameraTranslationMetric(_CameraMetric):
+ name = "camera_translation"
+ motion = "translation"
+ score_name = "camera_translation_rpe"
+ description = "Mean DA3/ViPE path-normalized translation RPE after Sim(3) alignment; lower is better."
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/pose_io.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/pose_io.py
new file mode 100644
index 000000000..e6dd09ad2
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/pose_io.py
@@ -0,0 +1,40 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Read full-frame target c2w, DA3 w2c, and ViPE c2w pose arrays."""
+
+from pathlib import Path
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+
+from .schemas import PoseTrajectory
+
+
+def load_trajectory(path, kind: str) -> PoseTrajectory:
+ error_type = DataInvalidError if kind == "target" else EvaluationError
+ try:
+ path = Path(path).expanduser().resolve()
+ if kind == "target" and path.suffix == ".npy":
+ poses = np.load(path, allow_pickle=False)
+ else:
+ with np.load(path, allow_pickle=False) as data:
+ poses = np.asarray(data["extrinsics" if kind == "da3" else "c2w"], dtype=np.float64)
+ if "frame_indices" in data and not np.array_equal(data["frame_indices"], np.arange(len(poses))):
+ raise ValueError("frame_indices must cover every frame in exact 0..N-1 order")
+ if kind == "da3":
+ matrices = np.asarray(poses, dtype=np.float64)
+ if matrices.ndim == 3 and matrices.shape[1:] == (3, 4):
+ padded = np.repeat(np.eye(4)[None], len(matrices), axis=0)
+ padded[:, :3] = matrices
+ matrices = padded
+ if matrices.ndim != 3 or matrices.shape[1:] != (4, 4):
+ raise ValueError("DA3 extrinsics must have shape (N,3,4) or (N,4,4)")
+ poses = np.linalg.inv(matrices)
+ return PoseTrajectory(poses, source=kind)
+ except (OSError, ValueError, KeyError, TypeError, np.linalg.LinAlgError) as error:
+ raise error_type(f"invalid {kind} pose artifact {path}: {error}") from error
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/preparation.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/preparation.py
new file mode 100644
index 000000000..5d03beea5
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/preparation.py
@@ -0,0 +1,93 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Read target camera inputs and prepare required predicted poses before scoring."""
+
+import json
+from pathlib import Path
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+from .metric import load_inputs, required_estimators
+
+
+def prepare_for_evaluation(samples, *, required=False):
+ for sample in samples:
+ sample.pop("camera_preparation_error", None)
+ try:
+ directory = Path(sample["video"]).parent
+ annotations = dict(sample.get("annotations") or {})
+ artifacts = dict(sample.get("artifacts") or {})
+ if "camera_motion_type" not in annotations:
+ source = directory / "trajectory.json"
+ if source.is_file():
+ value = json.loads(source.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise DataInvalidError("trajectory.json must be an object")
+ if "camera_motion_type" in value:
+ annotations["camera_motion_type"] = value["camera_motion_type"]
+ if "camera_motion_type" not in annotations:
+ if required or artifacts.get("target_poses") or (directory / "target_poses.npz").exists():
+ raise DataInvalidError("Camera evaluation requires camera_motion_type in trajectory.json")
+ continue
+ if annotations["camera_motion_type"] not in ("rotation", "translation", "mixed"):
+ raise DataInvalidError("camera_motion_type must be rotation, translation or mixed")
+ artifacts.setdefault("target_poses", str(directory / "target_poses.npz"))
+ artifacts.setdefault("da3", str(directory / "da3/results.npz"))
+ if annotations["camera_motion_type"] != "rotation":
+ artifacts.setdefault("vipe", str(directory / "vipe/results.npz"))
+ sample.update(annotations=annotations, artifacts=artifacts)
+ except (DataInvalidError, OSError, ValueError, KeyError, TypeError) as error:
+ sample["camera_preparation_error"] = {"status": "data_invalid", "message": str(error)}
+
+
+def prepare_artifacts(samples, context, metric_names):
+ eligible = [
+ sample
+ for sample in samples
+ if not sample.get("camera_preparation_error")
+ and (
+ (sample.get("annotations") or {}).get("camera_motion_type") == "mixed"
+ or "camera_" + str((sample.get("annotations") or {}).get("camera_motion_type")) in metric_names
+ )
+ ]
+ if not eligible:
+ return
+ from ...preparation.da3 import ensure_artifacts as da3
+ from ...preparation.vipe import ensure_artifacts as vipe
+
+ reader = VideoReader()
+ print(f"[{context.device}][Camera poses] 0/{len(eligible)}", flush=True)
+ for index, sample in enumerate(eligible, 1):
+ try:
+ load_inputs(sample, reader)
+ except Exception as error:
+ sample["camera_preparation_error"] = {
+ "status": "data_invalid" if isinstance(error, DataInvalidError) else "model_failure",
+ "message": str(error),
+ }
+ else:
+ motion = sample["annotations"]["camera_motion_type"]
+ sample["camera_artifact_errors"] = {}
+ for name in required_estimators(motion):
+ if Path(sample["artifacts"][name]).is_file():
+ continue
+ try:
+ if name == "da3":
+ da3(
+ sample,
+ context,
+ weight_metric="camera_rotation" if motion == "rotation" else "camera_translation",
+ require_scene=False,
+ )
+ else:
+ vipe(sample, context)
+ except Exception as error:
+ sample["camera_artifact_errors"][name] = f"{type(error).__name__}: {error}"
+ failed = sample.get("camera_preparation_error") or sample.get("camera_artifact_errors")
+ print(
+ f"[{context.device}][Camera poses] {index}/{len(eligible)} "
+ f"{'failed' if failed else 'ready'}: {sample['item_id']}",
+ flush=True,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/rpe.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/rpe.py
new file mode 100644
index 000000000..4d881bffc
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/rpe.py
@@ -0,0 +1,126 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Adjacent-step translation and rotation Relative Pose Error."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+from .schemas import PoseTrajectory
+from .trajectory import MOTION_TYPES
+
+
+def _rotation_errors_deg(error_rotations: np.ndarray) -> np.ndarray:
+ traces = np.trace(error_rotations, axis1=1, axis2=2)
+ cosines = np.clip((traces - 1.0) / 2.0, -1.0, 1.0)
+ return np.degrees(np.arccos(cosines))
+
+
+@dataclass(frozen=True)
+class RPEStatistics:
+ translation_errors: np.ndarray | None
+ rotation_errors_deg: np.ndarray
+
+ @property
+ def translation_mean(self) -> float | None:
+ if self.translation_errors is None:
+ return None
+ return float(np.mean(self.translation_errors))
+
+ @property
+ def translation_median(self) -> float | None:
+ if self.translation_errors is None:
+ return None
+ return float(np.median(self.translation_errors))
+
+ @property
+ def translation_path(self) -> float | None:
+ """Sum of adjacent errors on the unit-path canonical target."""
+ if self.translation_errors is None:
+ return None
+ return float(np.sum(self.translation_errors))
+
+ @property
+ def rotation_mean_deg(self) -> float:
+ return float(np.mean(self.rotation_errors_deg))
+
+ @property
+ def rotation_median_deg(self) -> float:
+ return float(np.median(self.rotation_errors_deg))
+
+ @property
+ def rotation_sum_deg(self) -> float:
+ return float(np.sum(self.rotation_errors_deg))
+
+ def rotation_path(self, target_rotation_path_deg: float) -> float:
+ """Normalize the accumulated rotation error by the target path."""
+
+ path = float(target_rotation_path_deg)
+ if not np.isfinite(path) or path <= 1e-12:
+ raise ValueError("target rotation path must be positive and finite")
+ return self.rotation_sum_deg / path
+
+
+def rotation_path_length_deg(trajectory: PoseTrajectory) -> float:
+ """Return the exact sum of adjacent target rotation magnitudes."""
+
+ rotations = trajectory.poses[:, :3, :3]
+ relative = np.einsum(
+ "nij,njk->nik",
+ np.swapaxes(rotations[:-1], 1, 2),
+ rotations[1:],
+ )
+ path = float(np.sum(_rotation_errors_deg(relative)))
+ if not np.isfinite(path):
+ raise ValueError("target rotation path must be finite")
+ return path
+
+
+def compute_rpe(
+ target: PoseTrajectory,
+ estimated: PoseTrajectory,
+ motion_type: str,
+) -> RPEStatistics:
+ if motion_type not in MOTION_TYPES:
+ raise ValueError(f"unsupported camera_motion_type: {motion_type!r}")
+ if target.num_poses != estimated.num_poses:
+ raise ValueError("target and estimated trajectories must have equal length")
+
+ target_poses = target.poses
+ estimated_poses = estimated.poses
+ if motion_type == "rotation":
+ target_relative = np.einsum(
+ "nij,njk->nik",
+ np.swapaxes(target_poses[:-1, :3, :3], 1, 2),
+ target_poses[1:, :3, :3],
+ )
+ estimated_relative = np.einsum(
+ "nij,njk->nik",
+ np.swapaxes(estimated_poses[:-1, :3, :3], 1, 2),
+ estimated_poses[1:, :3, :3],
+ )
+ rotation_error = np.einsum(
+ "nij,njk->nik",
+ np.swapaxes(target_relative, 1, 2),
+ estimated_relative,
+ )
+ translation_errors = None
+ else:
+ target_relative = np.linalg.inv(target_poses[:-1]) @ target_poses[1:]
+ estimated_relative = np.linalg.inv(estimated_poses[:-1]) @ estimated_poses[1:]
+ error_transform = np.linalg.inv(target_relative) @ estimated_relative
+ translation_errors = np.linalg.norm(error_transform[:, :3, 3], axis=1)
+ rotation_error = error_transform[:, :3, :3]
+
+ rotation_errors = _rotation_errors_deg(rotation_error)
+ if translation_errors is not None and not np.isfinite(translation_errors).all():
+ raise ValueError("translation RPE contains non-finite values")
+ if not np.isfinite(rotation_errors).all():
+ raise ValueError("rotation RPE contains non-finite values")
+ return RPEStatistics(
+ translation_errors=translation_errors,
+ rotation_errors_deg=rotation_errors,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/schemas.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/schemas.py
new file mode 100644
index 000000000..65bfee72b
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/schemas.py
@@ -0,0 +1,49 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Validated trajectory value objects for Camera Following."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+MIN_CAMERA_POSES = 9
+
+
+@dataclass(frozen=True)
+class PoseTrajectory:
+ poses: np.ndarray
+ source: str = ""
+
+ def __post_init__(self) -> None:
+ poses = np.asarray(self.poses, dtype=np.float64)
+ if poses.ndim != 3 or poses.shape[1:] not in {(3, 4), (4, 4)}:
+ raise ValueError(f"pose trajectory must have shape (N,3,4) or (N,4,4), got {poses.shape}")
+ if len(poses) < MIN_CAMERA_POSES:
+ raise ValueError(f"pose trajectory requires at least {MIN_CAMERA_POSES} poses; got {len(poses)}")
+ if poses.shape[1:] == (3, 4):
+ padded = np.repeat(np.eye(4, dtype=np.float64)[None], len(poses), axis=0)
+ padded[:, :3, :] = poses
+ poses = padded
+ if not np.isfinite(poses).all():
+ raise ValueError("pose trajectory must contain only finite values")
+ expected_bottom = np.array([0.0, 0.0, 0.0, 1.0])
+ if not np.allclose(poses[:, 3, :], expected_bottom, atol=1e-6):
+ raise ValueError("pose trajectory bottom rows must be [0,0,0,1]")
+ rotations = poses[:, :3, :3]
+ gram = np.swapaxes(rotations, 1, 2) @ rotations
+ determinants = np.linalg.det(rotations)
+ if not np.allclose(gram, np.eye(3), atol=5e-3) or not np.allclose(
+ determinants,
+ 1.0,
+ atol=5e-3,
+ ):
+ raise ValueError("pose trajectory contains an invalid rotation matrix")
+
+ object.__setattr__(self, "poses", np.ascontiguousarray(poses))
+
+ @property
+ def num_poses(self) -> int:
+ return len(self.poses)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/trajectory.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/trajectory.py
new file mode 100644
index 000000000..714f6fbf9
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/camera/trajectory.py
@@ -0,0 +1,93 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Reference path normalization and Sim(3) alignment for full-frame trajectories."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+from .schemas import PoseTrajectory
+
+MOTION_TYPES = frozenset({"translation", "rotation", "mixed"})
+
+
+def canonicalize_target(
+ trajectory: PoseTrajectory,
+ motion_type: str,
+ *,
+ epsilon: float = 1e-12,
+) -> tuple[PoseTrajectory, float | None]:
+ if motion_type not in MOTION_TYPES:
+ raise ValueError(f"camera_motion_type must be one of {sorted(MOTION_TYPES)}; got {motion_type!r}")
+ if motion_type == "rotation":
+ return trajectory, None
+
+ centers = trajectory.poses[:, :3, 3]
+ path_length = float(np.linalg.norm(np.diff(centers, axis=0), axis=1).sum())
+ if not np.isfinite(path_length) or path_length <= epsilon:
+ raise ValueError("translation/mixed target must have non-zero finite total path length")
+ poses = trajectory.poses.copy()
+ poses[:, :3, 3] = (centers - centers[0]) / path_length
+ return (
+ PoseTrajectory(
+ poses,
+ source=f"{trajectory.source}:canonical",
+ ),
+ path_length,
+ )
+
+
+@dataclass(frozen=True)
+class UmeyamaAlignment:
+ scale: float
+ rotation: np.ndarray
+ translation: np.ndarray
+ poses: np.ndarray
+
+
+def umeyama_align(
+ estimated: PoseTrajectory,
+ target: PoseTrajectory,
+ *,
+ epsilon: float = 1e-12,
+) -> UmeyamaAlignment:
+ if estimated.num_poses != target.num_poses:
+ raise ValueError("estimated and target trajectories must have equal length")
+ source = estimated.poses[:, :3, 3]
+ destination = target.poses[:, :3, 3]
+ source_mean = source.mean(axis=0)
+ destination_mean = destination.mean(axis=0)
+ source_centered = source - source_mean
+ destination_centered = destination - destination_mean
+ source_variance = float(np.square(source_centered).sum() / len(source))
+
+ if source_variance <= epsilon:
+ alignment_rotation = np.eye(3, dtype=np.float64)
+ scale = 0.0
+ else:
+ covariance = destination_centered.T @ source_centered / len(source)
+ left, singular_values, right_transpose = np.linalg.svd(covariance)
+ reflection = np.ones(3, dtype=np.float64)
+ if np.linalg.det(left @ right_transpose) < 0:
+ reflection[-1] = -1.0
+ reflection_guard = np.diag(reflection)
+ alignment_rotation = left @ reflection_guard @ right_transpose
+ scale = float((singular_values * reflection).sum() / (source_variance + epsilon))
+
+ translation = destination_mean - scale * alignment_rotation @ source_mean
+ aligned = estimated.poses.copy()
+ aligned[:, :3, :3] = np.einsum(
+ "ij,njk->nik",
+ alignment_rotation,
+ estimated.poses[:, :3, :3],
+ )
+ aligned[:, :3, 3] = scale * (alignment_rotation @ source.T).T + translation
+ return UmeyamaAlignment(
+ scale=scale,
+ rotation=alignment_rotation,
+ translation=translation,
+ poses=aligned,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/__init__.py
new file mode 100644
index 000000000..65874ceb5
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/__init__.py
@@ -0,0 +1,6 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+from .metric import DynamicMemoryMetric
+
+__all__ = ["DynamicMemoryMetric"]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/checklist.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/checklist.py
new file mode 100644
index 000000000..09eb95263
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/checklist.py
@@ -0,0 +1,81 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Validate one frozen eight-question Dynamic Memory checklist."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Any, Literal
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+CATEGORIES = (
+ "return_progress",
+ "persistence_reset",
+ "final_state",
+ "causal_physics",
+)
+EXPECTED_IDS = tuple(f"{category}_{index:02d}" for category in CATEGORIES for index in (1, 2))
+EVIDENCE_WINDOWS = {
+ "returned_view",
+ "returned_hold_sequence",
+ "returned_and_later_visible_sequence",
+ "final_stationary_view",
+ "ordered_visible_sequence",
+}
+
+
+@dataclass(frozen=True)
+class ChecklistQuestion:
+ id: str
+ category: str
+ question: str
+ expected_answer: Literal["yes", "no"]
+ evidence_window: str
+
+
+@dataclass(frozen=True)
+class CaseChecklist:
+ case_id: str
+ questions: tuple[ChecklistQuestion, ...]
+
+
+def parse_checklist(value: Any) -> CaseChecklist:
+ if not isinstance(value, dict):
+ raise DataInvalidError("checklist case must be an object")
+ case_id = str(value.get("case_id", ""))
+ if not re.fullmatch(r"case_[0-9]{3}", case_id):
+ raise DataInvalidError(f"invalid checklist case_id: {case_id!r}")
+ raw_questions = value.get("questions")
+ if not isinstance(raw_questions, list) or len(raw_questions) != 8:
+ raise DataInvalidError(f"{case_id}: checklist must contain 8 questions")
+ questions: list[ChecklistQuestion] = []
+ for index, raw in enumerate(raw_questions):
+ if not isinstance(raw, dict):
+ raise DataInvalidError(f"{case_id}: question {index} is not an object")
+ question_id = str(raw.get("id", ""))
+ expected_id = EXPECTED_IDS[index]
+ category = str(raw.get("category", ""))
+ if question_id != expected_id or category != expected_id.rsplit("_", 1)[0]:
+ raise DataInvalidError(f"{case_id}: invalid question layout at {index}")
+ text = str(raw.get("question", "")).strip()
+ if not text.endswith("?"):
+ raise DataInvalidError(f"{case_id}/{question_id}: invalid question text")
+ expected_answer = str(raw.get("expected_answer", "")).lower()
+ if expected_answer not in {"yes", "no"}:
+ raise DataInvalidError(f"{case_id}/{question_id}: expected_answer must be yes or no")
+ evidence_window = str(raw.get("evidence_window", "")).strip()
+ if evidence_window not in EVIDENCE_WINDOWS:
+ raise DataInvalidError(f"{case_id}/{question_id}: invalid evidence_window {evidence_window!r}")
+ questions.append(
+ ChecklistQuestion(
+ id=question_id,
+ category=category,
+ question=text,
+ expected_answer=expected_answer, # type: ignore[arg-type]
+ evidence_window=evidence_window,
+ )
+ )
+ return CaseChecklist(case_id=case_id, questions=tuple(questions))
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/metric.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/metric.py
new file mode 100644
index 000000000..5efa31934
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/metric.py
@@ -0,0 +1,97 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Run VLM answer generation and score calculation through one evaluation interface."""
+
+from dataclasses import asdict
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.aggregation import (
+ aggregate_video_summary,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import (
+ ItemResult,
+ ResultStatus,
+ UnitResult,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+from .checklist import parse_checklist
+from .scoring import aggregate_scores, score_answers
+from .vlm import HTTPVLMBackend, InvalidVLMResponse, VLMConfig, generate_answers
+
+
+class DynamicMemoryMetric(Metric):
+ name = "dynamic_memory"
+ description = "VLM answers to eight visual questions, scored against case-specific targets."
+
+ def __init__(self, *, backend=None, reader=None):
+ self.backend = backend
+ self.reader = reader or VideoReader()
+
+ def evaluate(self, sample, context):
+ if sample.get("category") not in (None, "dynamic_memory"):
+ return [UnitResult("checklist", status=ResultStatus.NOT_APPLICABLE)]
+ if error := sample.get("dynamic_preparation_error"):
+ raise DataInvalidError(error["message"])
+ config = VLMConfig(**context.metric_config(self.name))
+ annotations = sample["annotations"]
+ checklist = parse_checklist(annotations["dynamic_checklist"])
+ backend = self.backend or context.get_resource("dynamic:vlm", HTTPVLMBackend)
+ try:
+ response, indices, protocol, anchors = generate_answers(
+ sample["video"],
+ checklist,
+ annotations["lr_hold_protocol"],
+ config,
+ reader=self.reader,
+ backend=backend,
+ )
+ except InvalidVLMResponse as error:
+ return [
+ UnitResult(
+ "checklist",
+ status=ResultStatus.EVAL_FAILED,
+ error=str(error),
+ metadata={"raw_vlm_response": error.raw_response},
+ )
+ ]
+ units = score_answers(checklist, response, indices)
+ event = response.event_started
+ units[0].metadata["case_metadata"] = {
+ "case_id": checklist.case_id,
+ "vlm_model_id": config.model,
+ "vlm_backend": config.backend,
+ "vlm_settings": {key: value for key, value in asdict(config).items() if key != "api_key_env"},
+ "sampled_frame_indices": indices,
+ "event_started": event.answer,
+ "event_gate_applied": event.answer == "no",
+ "event_evidence_image_numbers": list(event.evidence_image_numbers),
+ "event_evidence_frame_indices": [indices[n - 1] for n in event.evidence_image_numbers],
+ "event_reason": event.reason,
+ "raw_vlm_response": response.raw_response,
+ "lr_hold_protocol": protocol.as_dict(),
+ "prompt_anchors": anchors.as_dict(),
+ "usage": response.usage,
+ }
+ return units
+
+ def aggregate_item(self, sample, units):
+ if units[0].status != ResultStatus.OK:
+ unit = units[0]
+ return ItemResult(sample["item_id"], status=unit.status, error=unit.error, metadata=dict(unit.metadata))
+ scores = aggregate_scores(units)
+ metadata = dict(units[0].metadata["case_metadata"])
+ metadata["questions"] = [
+ {
+ "id": unit.unit_id,
+ "score": unit.scores["score"],
+ **{key: value for key, value in unit.metadata.items() if key != "case_metadata"},
+ }
+ for unit in units
+ ]
+ return ItemResult(sample["item_id"], scores=scores, num_units=8, metadata=metadata)
+
+ def aggregate_summary(self, items):
+ return aggregate_video_summary(items)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/preparation.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/preparation.py
new file mode 100644
index 000000000..da9af8caf
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/preparation.py
@@ -0,0 +1,64 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Embed per-case checklist and planned LR-hold phases in the run manifest."""
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+
+from .checklist import parse_checklist
+from .protocol import LR_HOLD_TRAJECTORY_ID, LRHoldProtocol
+
+
+def prepare_for_evaluation(samples: list[dict]) -> None:
+ for sample in samples:
+ if sample.get("category") not in (None, "dynamic_memory"):
+ continue
+ sample.pop("dynamic_preparation_error", None)
+ try:
+ directory = Path(sample["video"]).parent
+ annotations = dict(sample.get("annotations") or {})
+ if "dynamic_checklist" not in annotations:
+ annotations["dynamic_checklist"] = json.loads(
+ (directory / "checklist.json").read_text(encoding="utf-8")
+ )
+ case = parse_checklist(annotations["dynamic_checklist"])
+ if sample.get("sample_id") not in (None, case.case_id):
+ raise DataInvalidError("checklist case_id does not match the video case")
+ annotations["dynamic_checklist"] = {
+ "case_id": case.case_id,
+ "questions": [asdict(question) for question in case.questions],
+ }
+ if "lr_hold_protocol" not in annotations:
+ trajectory = json.loads((directory / "trajectory.json").read_text(encoding="utf-8"))
+ annotations["lr_hold_protocol"] = {
+ "trajectory_id": LR_HOLD_TRAJECTORY_ID,
+ "native_trajectory_id": sample.get("condition_id") or LR_HOLD_TRAJECTORY_ID,
+ **trajectory,
+ }
+ protocol = annotations["lr_hold_protocol"]
+ # The declared length belongs to the planned trajectory, not to the decoded video.
+ protocol = {
+ "trajectory_id": protocol["trajectory_id"],
+ "native_trajectory_id": protocol["native_trajectory_id"],
+ "actions": protocol["actions"],
+ "phase_transitions": protocol["phase_transitions"],
+ "tail_hold_transitions": protocol.get("tail_hold_transitions", 0),
+ "declared_output_frames": protocol.get(
+ "declared_output_frames",
+ sum(protocol["phase_transitions"]) + protocol.get("tail_hold_transitions", 0) + 1,
+ ),
+ }
+ parsed = LRHoldProtocol.from_metadata(protocol, actual_output_frames=protocol["declared_output_frames"])
+ if sample.get("condition_id") not in (None, parsed.native_trajectory_id):
+ raise DataInvalidError("condition_id does not match LR-hold native trajectory")
+ annotations["lr_hold_protocol"] = protocol
+ sample["annotations"] = annotations
+ except (DataInvalidError, EvaluationError, OSError, ValueError, KeyError, TypeError, AttributeError) as error:
+ sample["dynamic_preparation_error"] = {"status": "data_invalid", "message": str(error)}
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/protocol.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/protocol.py
new file mode 100644
index 000000000..71467fd60
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/protocol.py
@@ -0,0 +1,227 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Planned five-phase protocol for the single-revisit LR-hold trajectory."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Iterable
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+
+LR_HOLD_TRAJECTORY_ID = "lr_hold_sym_0p5dpf"
+LR_HOLD_ACTIONS = ("hold", "yaw_left", "hold", "yaw_right", "hold")
+
+
+@dataclass(frozen=True)
+class FrameRange:
+ """Inclusive output-frame range."""
+
+ start: int
+ end: int
+
+ def __post_init__(self) -> None:
+ if self.start < 0 or self.end < self.start:
+ raise ValueError(f"invalid inclusive frame range: {self.start}..{self.end}")
+
+ def contains(self, frame_index: int) -> bool:
+ return self.start <= frame_index <= self.end
+
+ def as_dict(self) -> dict[str, int]:
+ return {"start": self.start, "end": self.end}
+
+
+@dataclass(frozen=True)
+class PromptAnchors:
+ """Sampled-image anchors supplied to the VLM rather than inferred by it."""
+
+ frame_map: tuple[dict[str, Any], ...]
+ departure_baseline_images: tuple[int, ...]
+ away_hold_images: tuple[int, ...]
+ returned_hold_images: tuple[int, ...]
+ returned_before_final_images: tuple[int, ...]
+ final_stationary_images: tuple[int, ...]
+
+ def as_dict(self) -> dict[str, list[int]]:
+ return {
+ "departure_baseline_images": list(self.departure_baseline_images),
+ "away_hold_images": list(self.away_hold_images),
+ "returned_hold_images": list(self.returned_hold_images),
+ "returned_before_final_images": list(self.returned_before_final_images),
+ "final_stationary_images": list(self.final_stationary_images),
+ }
+
+ def phase_ranges(self) -> dict[str, list[int]]:
+ ranges: dict[str, list[int]] = {}
+ for phase in (
+ "initial_hold",
+ "yaw_left",
+ "away_hold",
+ "yaw_right",
+ "returned_hold",
+ ):
+ numbers = [int(value["image_number"]) for value in self.frame_map if value["phase"] == phase]
+ if numbers:
+ ranges[phase] = [numbers[0], numbers[-1]]
+ return ranges
+
+ def compact_ranges(self) -> dict[str, list[int]]:
+ def bounds(numbers: tuple[int, ...]) -> list[int]:
+ return [numbers[0], numbers[-1]]
+
+ return {
+ "departure_baseline": bounds(self.departure_baseline_images),
+ "away_hold": bounds(self.away_hold_images),
+ "returned_hold": bounds(self.returned_hold_images),
+ "returned_before_final": bounds(self.returned_before_final_images),
+ "final_stationary": bounds(self.final_stationary_images),
+ }
+
+
+@dataclass(frozen=True)
+class LRHoldProtocol:
+ """Model-native planned LR-hold phases mapped onto one output video."""
+
+ trajectory_id: str
+ native_trajectory_id: str
+ phase_transitions: tuple[int, int, int, int, int]
+ tail_hold_transitions: int
+ declared_output_frames: int
+ actual_output_frames: int
+ initial_hold: FrameRange
+ yaw_left: FrameRange
+ away_hold: FrameRange
+ yaw_right: FrameRange
+ returned_hold: FrameRange
+
+ @classmethod
+ def from_metadata(
+ cls,
+ value: Any,
+ *,
+ actual_output_frames: int,
+ ) -> "LRHoldProtocol":
+ if not isinstance(value, dict):
+ raise DataInvalidError("metadata.lr_hold_protocol must be an object")
+ trajectory_id = str(value.get("trajectory_id", ""))
+ if trajectory_id != LR_HOLD_TRAJECTORY_ID:
+ raise DataInvalidError(f"Dynamic Memory requires {LR_HOLD_TRAJECTORY_ID}, got {trajectory_id!r}")
+ native_trajectory_id = str(value.get("native_trajectory_id", ""))
+ if not (
+ native_trajectory_id == LR_HOLD_TRAJECTORY_ID
+ or native_trajectory_id.startswith(LR_HOLD_TRAJECTORY_ID + "_action")
+ ):
+ raise DataInvalidError(f"invalid native LR-hold trajectory: {native_trajectory_id!r}")
+ raw_actions = value.get("actions")
+ if tuple(raw_actions or ()) != LR_HOLD_ACTIONS:
+ raise DataInvalidError(f"LR-hold actions must be {LR_HOLD_ACTIONS}, got {raw_actions!r}")
+ raw_counts = value.get("phase_transitions")
+ if (
+ not isinstance(raw_counts, list)
+ or len(raw_counts) != 5
+ or any(isinstance(count, bool) or not isinstance(count, int) for count in raw_counts)
+ or any(count <= 0 for count in raw_counts)
+ ):
+ raise DataInvalidError("lr_hold_protocol.phase_transitions must contain five positive integers")
+ counts = tuple(raw_counts)
+ tail_hold = value.get("tail_hold_transitions", 0)
+ declared_frames = value.get("declared_output_frames")
+ if isinstance(tail_hold, bool) or not isinstance(tail_hold, int) or tail_hold < 0:
+ raise DataInvalidError("tail_hold_transitions must be a non-negative integer")
+ if isinstance(declared_frames, bool) or not isinstance(declared_frames, int) or declared_frames < 2:
+ raise DataInvalidError("declared_output_frames must be an integer >= 2")
+ if actual_output_frames < 2:
+ raise EvaluationError("LR-hold video must contain at least two frames")
+ if sum(counts) + tail_hold != declared_frames - 1:
+ raise DataInvalidError("phase transitions and tail hold do not cover declared output frames")
+
+ native_boundaries = []
+ cumulative = 0
+ for count in counts[:4]:
+ cumulative += count
+ native_boundaries.append(cumulative)
+
+ def map_frame(index: int) -> int:
+ return round(index * (actual_output_frames - 1) / (declared_frames - 1))
+
+ b1, b2, b3, b4 = (map_frame(value) for value in native_boundaries)
+ last = actual_output_frames - 1
+ if not (0 < b1 < b2 < b3 < b4 < last):
+ raise EvaluationError(
+ f"mapped LR-hold phase boundaries are not strictly increasing: {(b1, b2, b3, b4, last)}"
+ )
+ return cls(
+ trajectory_id=trajectory_id,
+ native_trajectory_id=native_trajectory_id,
+ phase_transitions=counts, # type: ignore[arg-type]
+ tail_hold_transitions=tail_hold,
+ declared_output_frames=declared_frames,
+ actual_output_frames=actual_output_frames,
+ initial_hold=FrameRange(0, b1),
+ yaw_left=FrameRange(b1 + 1, b2 - 1),
+ away_hold=FrameRange(b2, b3),
+ yaw_right=FrameRange(b3 + 1, b4 - 1),
+ returned_hold=FrameRange(b4, last),
+ )
+
+ def phase_for_frame(self, frame_index: int) -> str:
+ if not 0 <= frame_index < self.actual_output_frames:
+ raise ValueError(f"frame {frame_index} outside [0, {self.actual_output_frames - 1}]")
+ if self.initial_hold.contains(frame_index):
+ return "initial_hold"
+ if self.yaw_left.contains(frame_index):
+ return "yaw_left"
+ if self.away_hold.contains(frame_index):
+ return "away_hold"
+ if self.yaw_right.contains(frame_index):
+ return "yaw_right"
+ return "returned_hold"
+
+ def prompt_anchors(self, sampled_frame_indices: Iterable[int]) -> PromptAnchors:
+ indices = tuple(int(index) for index in sampled_frame_indices)
+ if not indices or tuple(sorted(set(indices))) != indices:
+ raise ValueError("sampled frame indices must be sorted and unique")
+ frame_map = tuple(
+ {
+ "image_number": image_number,
+ "source_frame_index": frame_index,
+ "phase": self.phase_for_frame(frame_index),
+ }
+ for image_number, frame_index in enumerate(indices, start=1)
+ )
+
+ def image_numbers(phase: str) -> tuple[int, ...]:
+ return tuple(int(value["image_number"]) for value in frame_map if value["phase"] == phase)
+
+ initial = image_numbers("initial_hold")
+ away = image_numbers("away_hold")
+ returned = image_numbers("returned_hold")
+ if len(initial) < 3 or len(away) < 1 or len(returned) < 4:
+ raise EvaluationError("stride sampling does not preserve enough LR-hold anchor images")
+ return PromptAnchors(
+ frame_map=frame_map,
+ departure_baseline_images=initial[-3:],
+ away_hold_images=away,
+ returned_hold_images=returned,
+ returned_before_final_images=returned[:-3],
+ final_stationary_images=returned[-3:],
+ )
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "trajectory_id": self.trajectory_id,
+ "native_trajectory_id": self.native_trajectory_id,
+ "phase_transitions": list(self.phase_transitions),
+ "tail_hold_transitions": self.tail_hold_transitions,
+ "declared_output_frames": self.declared_output_frames,
+ "actual_output_frames": self.actual_output_frames,
+ "initial_hold": self.initial_hold.as_dict(),
+ "yaw_left": self.yaw_left.as_dict(),
+ "away_hold": self.away_hold.as_dict(),
+ "yaw_right": self.yaw_right.as_dict(),
+ "returned_hold": self.returned_hold.as_dict(),
+ }
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/scoring.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/scoring.py
new file mode 100644
index 000000000..5985adcda
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/scoring.py
@@ -0,0 +1,49 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Calculate Dynamic Memory scores from structured answers, without model inference."""
+
+from statistics import mean
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import UnitResult
+
+from .checklist import CATEGORIES
+
+
+def score_answers(checklist, response, frame_indices) -> list[UnitResult]:
+ """Stage two: compare each answer with its target and apply the event prerequisite."""
+ event_started = response.event_started.answer == "yes"
+ units = []
+ for question, answer in zip(checklist.questions, response.answers, strict=True):
+ ungated = float(answer.answer == question.expected_answer)
+ units.append(
+ UnitResult(
+ question.id,
+ unit_type="question",
+ scores={"score": ungated if event_started else 0.0},
+ metadata={
+ "category": question.category,
+ "question": question.question,
+ "expected_answer": question.expected_answer,
+ "predicted_answer": answer.answer,
+ "evidence_window": question.evidence_window,
+ "reason": answer.reason,
+ "evidence_image_numbers": list(answer.evidence_image_numbers),
+ "evidence_frame_indices": [frame_indices[n - 1] for n in answer.evidence_image_numbers],
+ "ungated_score": ungated,
+ },
+ )
+ )
+ return units
+
+
+def aggregate_scores(units) -> dict[str, float]:
+ if len(units) != 8:
+ raise EvaluationError("Dynamic Memory requires 8 question units")
+ scores = {"score": mean(unit.scores["score"] for unit in units)}
+ for category in CATEGORIES:
+ scores[f"{category}_score"] = mean(
+ unit.scores["score"] for unit in units if unit.metadata["category"] == category
+ )
+ return scores
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/vlm.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/vlm.py
new file mode 100644
index 000000000..55e6ff4ff
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/dynamic/vlm.py
@@ -0,0 +1,396 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Generate structured VLM answers from chronologically sampled video frames."""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass, field, replace
+from typing import Literal
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+from .checklist import CaseChecklist
+from .protocol import LRHoldProtocol, PromptAnchors
+
+DEFAULT_STRIDE = 8
+DEFAULT_JPEG_QUALITY = 90
+
+
+@dataclass(frozen=True)
+class VLMConfig:
+ base_url: str = "http://127.0.0.1:8001/v1"
+ model: str = "Qwen3.8-27B"
+ max_tokens: int = 4096
+ timeout_seconds: float = 600.0
+ jpeg_quality: int = DEFAULT_JPEG_QUALITY
+ sampling_stride_frames: int = DEFAULT_STRIDE
+ backend: str = "local"
+ api_key_env: str = "WORLDMEMBENCH_API_KEY"
+
+
+@dataclass(frozen=True)
+class QuestionAnswer:
+ id: str
+ answer: Literal["yes", "no"]
+ evidence_image_numbers: tuple[int, ...]
+ reason: str
+
+
+@dataclass(frozen=True)
+class EventStartedAnswer:
+ answer: Literal["yes", "no"]
+ evidence_image_numbers: tuple[int, ...]
+ reason: str
+
+
+@dataclass(frozen=True)
+class VLMResponse:
+ raw_response: str
+ event_started: EventStartedAnswer
+ answers: tuple[QuestionAnswer, ...]
+ usage: dict = field(default_factory=dict)
+
+
+class InvalidVLMResponse(EvaluationError):
+ def __init__(self, message: str, *, raw_response: str | None = None) -> None:
+ super().__init__(message)
+ self.raw_response = raw_response
+
+
+def sample_indices(total_frames: int, *, stride: int = DEFAULT_STRIDE) -> list[int]:
+ if total_frames <= 0:
+ return []
+ if stride <= 0:
+ raise ValueError("sampling stride must be positive")
+ indices = list(range(0, total_frames, stride))
+ if indices[-1] != total_frames - 1:
+ indices.append(total_frames - 1)
+ return indices
+
+
+def encode_jpegs(
+ frames_rgb: list,
+ *,
+ quality: int = DEFAULT_JPEG_QUALITY,
+) -> list[bytes]:
+ import cv2
+
+ encoded: list[bytes] = []
+ for frame in frames_rgb:
+ ok, buffer = cv2.imencode(
+ ".jpg",
+ cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
+ [int(cv2.IMWRITE_JPEG_QUALITY), quality],
+ )
+ if not ok:
+ raise EvaluationError("failed to JPEG-encode a sampled frame")
+ encoded.append(bytes(buffer))
+ return encoded
+
+
+def generate_answers(video_path, checklist, trajectory, config, *, reader, backend):
+ """Stage one: sample the video and obtain answers in one VLM request."""
+ if config.backend not in ("local", "api") or not config.base_url or not config.model:
+ raise EvaluationError("VLM requires backend local/api, base_url and model")
+ if config.max_tokens <= 0 or config.timeout_seconds <= 0 or not 1 <= config.jpeg_quality <= 100:
+ raise EvaluationError("invalid VLM token, timeout or JPEG quality setting")
+ info = reader.probe(video_path)
+ indices = sample_indices(info.total_frames, stride=config.sampling_stride_frames)
+ protocol = LRHoldProtocol.from_metadata(trajectory, actual_output_frames=info.total_frames)
+ anchors = protocol.prompt_anchors(indices)
+ images = encode_jpegs(
+ [frame.rgb for frame in reader.read_indices(video_path, indices)], quality=config.jpeg_quality
+ )
+ initial = anchors.phase_ranges()["initial_hold"]
+ response = backend.generate(
+ prompt=build_prompt(checklist, anchors),
+ jpeg_images=images,
+ config=config,
+ expected_ids=tuple(question.id for question in checklist.questions),
+ event_image_numbers=tuple(range(initial[0], initial[1] + 1)),
+ )
+ return response, indices, protocol, anchors
+
+
+def build_prompt(checklist: CaseChecklist, anchors: PromptAnchors) -> str:
+ """Ask the VLM for visual evidence without disclosing expected answers."""
+
+ questions = [
+ {
+ "id": question.id,
+ "category": question.category,
+ "evidence_window": question.evidence_window,
+ "question": question.question,
+ }
+ for question in checklist.questions
+ ]
+ return "\n".join(
+ [
+ "You are judging one generated Dynamic Memory LR-hold video using eight case-specific questions.",
+ "The attached images are sampled in chronological order.",
+ "The target generation prompt and expected answers are deliberately hidden.",
+ "Use only visible evidence; do not complete a plausible story from the question wording.",
+ "Question wording describes the intended evaluation target, but it is not visual evidence.",
+ "For a change claim, cite visible before-and-after images that show a concrete difference; otherwise answer no.",
+ "If the required subject or object disappears, is replaced, or changes into a different scene, do not treat its absence as successful completion.",
+ "In the reason, describe only concrete evidence visible in the cited images and do not invent an unseen state or event.",
+ "The evaluator has assigned the images to the model's planned camera phases.",
+ "Each range below is an inclusive pair of 1-based image numbers.",
+ "Planned phase image ranges:",
+ json.dumps(anchors.phase_ranges(), ensure_ascii=False, indent=2),
+ "",
+ "Useful comparison ranges:",
+ json.dumps(anchors.compact_ranges(), ensure_ascii=False, indent=2),
+ "",
+ "Phase guidance:",
+ "- returned_view means the returned_hold range, compared with the departure_baseline range when the question asks about change.",
+ "- returned_hold_sequence means the progression inside the returned_hold range.",
+ "- returned_and_later_visible_sequence means the returned_hold range through its final images.",
+ "- final_stationary_view means the final_stationary range.",
+ "- ordered_visible_sequence may use the full chronological image sequence.",
+ "- For a comparison or temporal claim, use visible before/after evidence when available.",
+ "- If an event never occurs, say that directly. Never invent a reset, reversal, worsening, disappearance, or change that is not visible.",
+ "- Interpret extent words relative to the stated baseline, not by absolute impression.",
+ "- Camera motion, framing, lighting, an action pose, or a replaced subject alone is not evidence of state progress.",
+ "- If the exact state is not visibly established, answer no.",
+ "- Cite only decisive 1-based image numbers.",
+ "",
+ "Event-start prerequisite:",
+ "- First decide whether the central event described by the questions visibly starts during initial_hold, before the camera leaves the original view.",
+ "- event_started is yes only when at least two initial_hold images show concrete temporal progress by the principal subject or object.",
+ "- Mere pose, contact, camera motion, background motion, or a plausible intention is not an event start.",
+ "- If no relevant action or state change is visibly underway by the end of initial_hold, event_started is no.",
+ "- Even when event_started is no, answer all eight questions from visible evidence; the evaluator applies the zero-score gate.",
+ "",
+ "Questions (expected answers are intentionally hidden):",
+ json.dumps(questions, ensure_ascii=False, indent=2),
+ "",
+ "Return one JSON object with exactly the keys event_started and answers.",
+ "event_started must contain:",
+ '- answer: "yes" or "no";',
+ "- evidence_image_numbers: at least two decisive 1-based image numbers from initial_hold;",
+ "- reason: one concise sentence describing the visible change, or the visible lack of change.",
+ "answers must be a list of exactly eight objects in the same ID order.",
+ "Each object must contain:",
+ "- id: the question ID;",
+ '- answer: "yes" or "no";',
+ "- evidence_image_numbers: a short list of decisive 1-based image numbers;",
+ "- reason: one concise sentence describing only the cited visible evidence.",
+ "Return JSON only.",
+ ]
+ )
+
+
+def parse_response(
+ raw_text: str,
+ expected_ids: tuple[str, ...],
+ *,
+ num_images: int,
+ event_image_numbers: tuple[int, ...] | None = None,
+) -> VLMResponse:
+ text = raw_text.strip()
+ if text.startswith("```") and text.endswith("```"):
+ text = "\n".join(text.splitlines()[1:-1]).strip()
+ try:
+ value = json.loads(text)
+ except json.JSONDecodeError as error:
+ # Accept only lossless repair of missing outer braces around the two
+ # required top-level fields. Do not tolerate truncation or extra fields.
+ try:
+ wrapped = json.loads("{" + text + "}")
+ except json.JSONDecodeError:
+ raise InvalidVLMResponse("VLM response is not valid JSON") from error
+ if not isinstance(wrapped, dict) or set(wrapped) != {
+ "event_started",
+ "answers",
+ }:
+ raise InvalidVLMResponse("VLM response is not valid JSON") from error
+ value = wrapped
+ if not isinstance(value, dict) or set(value) != {"event_started", "answers"}:
+ raise InvalidVLMResponse("VLM response must contain exactly event_started and answers")
+ event = _parse_event_started(
+ value.get("event_started"),
+ num_images=num_images,
+ allowed_image_numbers=event_image_numbers,
+ )
+ rows = _answer_rows(value)
+ if len(rows) != len(expected_ids):
+ raise InvalidVLMResponse(f"expected {len(expected_ids)} answers, got {len(rows)}")
+ answers: list[QuestionAnswer] = []
+ for index, (answer, expected_id) in enumerate(zip(rows, expected_ids, strict=True)):
+ if not isinstance(answer, dict):
+ raise InvalidVLMResponse(f"answer {index} must be an object")
+ if answer.get("id") != expected_id:
+ raise InvalidVLMResponse(f"answer {index} ID must be {expected_id!r}")
+ predicted = str(answer.get("answer", "")).strip().lower()
+ if predicted not in {"yes", "no"}:
+ raise InvalidVLMResponse(f"{expected_id}: answer must be yes or no")
+ evidence = answer.get("evidence_image_numbers")
+ if not isinstance(evidence, list) or not evidence:
+ raise InvalidVLMResponse(f"{expected_id}: evidence_image_numbers must be a non-empty list")
+ try:
+ evidence_numbers = tuple(int(number) for number in evidence)
+ except (TypeError, ValueError) as error:
+ raise InvalidVLMResponse(f"{expected_id}: evidence image number must be an integer") from error
+ if any(number < 1 or number > num_images for number in evidence_numbers):
+ raise InvalidVLMResponse(f"{expected_id}: evidence image number is outside [1, {num_images}]")
+ reason = str(answer.get("reason", "")).strip()
+ if not reason:
+ raise InvalidVLMResponse(f"{expected_id}: reason is required")
+ answers.append(
+ QuestionAnswer(
+ id=expected_id,
+ answer=predicted, # type: ignore[arg-type]
+ evidence_image_numbers=evidence_numbers,
+ reason=reason,
+ )
+ )
+ return VLMResponse(
+ raw_response=raw_text,
+ event_started=event,
+ answers=tuple(answers),
+ )
+
+
+def _parse_event_started(
+ value,
+ *,
+ num_images: int,
+ allowed_image_numbers: tuple[int, ...] | None,
+) -> EventStartedAnswer:
+ if not isinstance(value, dict):
+ raise InvalidVLMResponse("event_started must be an object")
+ answer = str(value.get("answer", "")).strip().lower()
+ if answer not in {"yes", "no"}:
+ raise InvalidVLMResponse("event_started.answer must be yes or no")
+ evidence = value.get("evidence_image_numbers")
+ if not isinstance(evidence, list) or len(evidence) < 2:
+ raise InvalidVLMResponse("event_started.evidence_image_numbers must contain at least two images")
+ try:
+ evidence_numbers = tuple(int(number) for number in evidence)
+ except (TypeError, ValueError) as error:
+ raise InvalidVLMResponse("event_started evidence image number must be an integer") from error
+ if len(set(evidence_numbers)) < 2:
+ raise InvalidVLMResponse("event_started evidence images must be distinct")
+ if any(number < 1 or number > num_images for number in evidence_numbers):
+ raise InvalidVLMResponse(f"event_started evidence image number is outside [1, {num_images}]")
+ if allowed_image_numbers is not None:
+ allowed = set(allowed_image_numbers)
+ if any(number not in allowed for number in evidence_numbers):
+ raise InvalidVLMResponse("event_started evidence images must come from initial_hold")
+ reason = str(value.get("reason", "")).strip()
+ if not reason:
+ raise InvalidVLMResponse("event_started.reason is required")
+ return EventStartedAnswer(
+ answer=answer, # type: ignore[arg-type]
+ evidence_image_numbers=evidence_numbers,
+ reason=reason,
+ )
+
+
+def _answer_rows(value) -> list:
+ """Read the question answers from the structured VLM response."""
+
+ rows = value.get("answers")
+ if isinstance(rows, list):
+ return rows
+ raise InvalidVLMResponse("VLM response must contain answers")
+
+
+def chat_completions_url(base_url: str) -> str:
+ base = base_url.rstrip("/")
+ path = urllib.parse.urlparse(base).path.rstrip("/")
+ if path.endswith("/chat/completions"):
+ return base
+ if path.endswith("/v1"):
+ return base + "/chat/completions"
+ return base + "/v1/chat/completions"
+
+
+def build_payload(
+ config: VLMConfig,
+ *,
+ prompt: str,
+ jpeg_images: list[bytes],
+) -> dict:
+ content = [
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64," + base64.b64encode(image).decode("ascii")},
+ }
+ for image in jpeg_images
+ ]
+ content.append({"type": "text", "text": prompt})
+ payload = {
+ "model": config.model,
+ "messages": [{"role": "user", "content": content}],
+ "temperature": 0,
+ "max_tokens": config.max_tokens,
+ "chat_template_kwargs": {
+ "enable_thinking": False,
+ "preserve_thinking": False,
+ },
+ }
+ if config.backend == "api":
+ payload.pop("chat_template_kwargs")
+ payload["enable_thinking"] = False
+ return payload
+
+
+class HTTPVLMBackend:
+ def generate(
+ self,
+ *,
+ prompt: str,
+ jpeg_images: list[bytes],
+ config: VLMConfig,
+ expected_ids: tuple[str, ...],
+ event_image_numbers: tuple[int, ...],
+ ) -> VLMResponse:
+ headers = {"Content-Type": "application/json"}
+ key = ""
+ if config.backend == "api":
+ key = os.environ.get(config.api_key_env, "")
+ if not key:
+ raise EvaluationError(f"set the API key environment variable {config.api_key_env}")
+ headers["Authorization"] = "Bearer " + key
+ request = urllib.request.Request(
+ chat_completions_url(config.base_url),
+ data=json.dumps(build_payload(config, prompt=prompt, jpeg_images=jpeg_images)).encode("utf-8"),
+ headers=headers,
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=config.timeout_seconds) as response:
+ body = json.loads(response.read())
+ except urllib.error.HTTPError as error:
+ detail = error.read().decode("utf-8", errors="replace")[:2000]
+ if key:
+ detail = detail.replace(key, "[REDACTED]")
+ raise EvaluationError(f"VLM HTTP {error.code}: {detail}") from error
+ except urllib.error.URLError as error:
+ raise EvaluationError(f"VLM request failed: {error.reason}") from error
+ try:
+ raw_text = body["choices"][0]["message"]["content"]
+ except (KeyError, IndexError, TypeError) as error:
+ raise EvaluationError("VLM response has no chat content") from error
+ if not isinstance(raw_text, str) or not raw_text.strip():
+ raise EvaluationError("VLM returned empty chat content")
+ try:
+ if body["choices"][0].get("finish_reason") == "length":
+ raise InvalidVLMResponse("VLM output reached max_tokens")
+ parsed = parse_response(
+ raw_text,
+ expected_ids,
+ num_images=len(jpeg_images),
+ event_image_numbers=event_image_numbers,
+ )
+ return replace(parsed, usage=body.get("usage") or {})
+ except InvalidVLMResponse as error:
+ raise InvalidVLMResponse(str(error), raw_response=raw_text) from error
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/__init__.py
new file mode 100644
index 000000000..90e9af0a3
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Formal Visible Memory, Depth Spearman and Scene Reconstruction metrics."""
+
+from .metric import EnvironmentDepthFidelityMetric, EnvironmentSceneReconstructionMetric, EnvironmentVisibleMemoryMetric
+
+__all__ = ["EnvironmentVisibleMemoryMetric", "EnvironmentDepthFidelityMetric", "EnvironmentSceneReconstructionMetric"]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/artifacts.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/artifacts.py
new file mode 100644
index 000000000..3f5773edb
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/artifacts.py
@@ -0,0 +1,86 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Read the two DA3 artifacts used by Environment scoring."""
+
+from dataclasses import dataclass
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+
+def load_poses(path, total_frames: int) -> np.ndarray:
+ with np.load(path, allow_pickle=False) as data:
+ matrices = np.asarray(data["extrinsics"], dtype=np.float64)
+ indices = np.asarray(data["frame_indices"])
+ if total_frames < 9 or len(matrices) != total_frames:
+ raise DataInvalidError("DA3 must provide one pose per video frame, with at least nine frames")
+ if not np.array_equal(indices, np.arange(total_frames)):
+ raise DataInvalidError("DA3 frame_indices must equal 0..video_frame_count-1")
+ if matrices.shape == (total_frames, 3, 4):
+ padded = np.repeat(np.eye(4)[None], total_frames, axis=0)
+ padded[:, :3] = matrices
+ matrices = padded
+ if matrices.shape != (total_frames, 4, 4) or not np.isfinite(matrices).all():
+ raise DataInvalidError("DA3 extrinsics must be finite [T,3,4] or [T,4,4] matrices")
+ poses = np.linalg.inv(matrices)
+ rotations = poses[:, :3, :3]
+ if (
+ not np.allclose(poses[:, 3], [0, 0, 0, 1], atol=1e-6)
+ or not np.allclose(np.swapaxes(rotations, 1, 2) @ rotations, np.eye(3), atol=5e-3)
+ or not np.allclose(np.linalg.det(rotations), 1.0, atol=5e-3)
+ ):
+ raise DataInvalidError("DA3 extrinsics contain invalid camera transformations")
+ return np.ascontiguousarray(poses)
+
+
+@dataclass(frozen=True)
+class SceneGeometry:
+ frame_indices: np.ndarray
+ depths: np.ndarray
+ confidence: np.ndarray
+ intrinsics: np.ndarray
+ c2w: np.ndarray
+
+ @property
+ def index_to_position(self) -> dict[int, int]:
+ return {int(index): position for position, index in enumerate(self.frame_indices)}
+
+
+def load_scene_geometry(path, total_frames: int) -> SceneGeometry:
+ with np.load(path, allow_pickle=False) as data:
+ if int(data["schema_version"].item()) != 1:
+ raise DataInvalidError("unsupported DA3 scene geometry schema")
+ if int(data["source_frame_count"].item()) != total_frames:
+ raise DataInvalidError("DA3 scene geometry source_frame_count does not match the video")
+ geometry = SceneGeometry(
+ np.asarray(data["frame_indices"], dtype=np.int64),
+ np.asarray(data["depths"], dtype=np.float32),
+ np.asarray(data["confidence"], dtype=np.float32),
+ np.asarray(data["intrinsics"], dtype=np.float64),
+ np.asarray(data["c2w"], dtype=np.float64),
+ )
+ indices, depths = geometry.frame_indices, geometry.depths
+ count = len(indices)
+ if (
+ indices.ndim != 1
+ or count < 2
+ or indices[0] != 0
+ or indices[-1] >= total_frames
+ or not np.all(np.diff(indices) > 0)
+ ):
+ raise DataInvalidError("scene frame_indices must start at zero and increase within the video")
+ if (
+ depths.ndim != 3
+ or len(depths) != count
+ or geometry.confidence.shape != depths.shape
+ or geometry.intrinsics.shape != (count, 3, 3)
+ or geometry.c2w.shape != (count, 4, 4)
+ ):
+ raise DataInvalidError("inconsistent DA3 scene geometry array shapes")
+ if any(not np.isfinite(array).all() for array in (depths, geometry.confidence, geometry.intrinsics, geometry.c2w)):
+ raise DataInvalidError("DA3 scene geometry contains non-finite values")
+ if np.any(depths < 0) or np.any(np.sum(depths > 0, axis=(1, 2)) == 0) or np.any(geometry.confidence < 0):
+ raise DataInvalidError("DA3 depth/confidence must be non-negative with positive depth per frame")
+ return geometry
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/backends.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/backends.py
new file mode 100644
index 000000000..a18b213f9
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/backends.py
@@ -0,0 +1,95 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Local-only full-frame DINOv2 and SegFormer inference."""
+
+from pathlib import Path
+from types import SimpleNamespace
+
+import numpy as np
+
+DEFAULT_REJECT_LABELS = (
+ "person",
+ "car",
+ "boat",
+ "bus",
+ "truck",
+ "van",
+ "ship",
+ "minibike",
+ "animal",
+ "bicycle",
+ "sky",
+ "water",
+ "sea",
+ "river",
+ "lake",
+ "mirror",
+)
+
+
+class DinoVisibleFeatures:
+ def __init__(self, checkpoint, device, batch_size=8, *, model):
+ import torch
+ from transformers import AutoImageProcessor
+
+ if batch_size < 1:
+ raise ValueError("DINO batch size must be positive")
+ checkpoint = Path(checkpoint).expanduser().resolve()
+ self.torch, self.device, self.batch_size = torch, torch.device(device), batch_size
+ self.processor = AutoImageProcessor.from_pretrained(checkpoint, local_files_only=True)
+ self.model = model
+
+ def embed(self, images_rgb):
+ from PIL import Image
+
+ output = []
+ for start in range(0, len(images_rgb), self.batch_size):
+ images = [
+ Image.fromarray(np.asarray(value, dtype=np.uint8))
+ for value in images_rgb[start : start + self.batch_size]
+ ]
+ inputs = self.processor(images=images, return_tensors="pt").to(self.device)
+ with self.torch.inference_mode():
+ features = self.model(**inputs).float()
+ features = self.torch.nn.functional.normalize(features, dim=-1)
+ output.append(features.cpu().numpy())
+ return np.concatenate(output, axis=0)
+
+
+class SegformerMasks:
+ def __init__(self, checkpoint, device, batch_size=4, reject_labels=DEFAULT_REJECT_LABELS):
+ import torch
+ from transformers import AutoImageProcessor, AutoModelForSemanticSegmentation
+
+ if batch_size < 1:
+ raise ValueError("semantic batch size must be positive")
+ checkpoint = Path(checkpoint).expanduser().resolve()
+ self.torch, self.device, self.batch_size = torch, torch.device(device), batch_size
+ self.processor = AutoImageProcessor.from_pretrained(checkpoint, local_files_only=True)
+ self.model = (
+ AutoModelForSemanticSegmentation.from_pretrained(checkpoint, local_files_only=True).to(self.device).eval()
+ )
+ rejected = {str(label).strip().casefold() for label in reject_labels}
+ # Exact label matching: rejecting car must not reject carpet.
+ self.rejected_ids = [
+ int(index)
+ for index, label in self.model.config.id2label.items()
+ if str(label).strip().casefold() in rejected
+ ]
+
+ def keep_masks(self, images_rgb):
+ from PIL import Image
+
+ output = []
+ for start in range(0, len(images_rgb), self.batch_size):
+ chunk = images_rgb[start : start + self.batch_size]
+ images = [Image.fromarray(np.asarray(value, dtype=np.uint8)) for value in chunk]
+ inputs = self.processor(images=images, return_tensors="pt").to(self.device)
+ with self.torch.inference_mode():
+ logits = self.model(**inputs).logits
+ segmentations = self.processor.post_process_semantic_segmentation(
+ SimpleNamespace(logits=logits), target_sizes=[tuple(image.shape[:2]) for image in chunk]
+ )
+ output.extend(~np.isin(labels.cpu().numpy(), self.rejected_ids) for labels in segmentations)
+ return output
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/geometry.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/geometry.py
new file mode 100644
index 000000000..0e238da15
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/geometry.py
@@ -0,0 +1,146 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Reference DA3 return selection and spherical nearest-surface reconstruction."""
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+def rotation_distance_deg(first, second) -> float:
+ relative = first.T @ second
+ cosine = float(np.clip((np.trace(relative) - 1.0) / 2.0, -1.0, 1.0))
+ return float(np.degrees(np.arccos(cosine)))
+
+
+@dataclass(frozen=True)
+class ReturnCandidate:
+ slot_id: int
+ axis: str
+ return_index: int
+ rotation_error_deg: float
+ returned: bool
+
+
+def select_returns(
+ poses, protocol, *, rotation_threshold_deg=30.0, translation_threshold=0.15, return_buffer_frames=30
+):
+ if rotation_threshold_deg <= 0 or return_buffer_frames < 0:
+ raise ValueError("rotation threshold must be positive and return buffer non-negative")
+ results = []
+ reference = poses[0]
+ for slot in protocol.slots:
+ window = protocol.phases[slot.return_phase]
+ # Extend only to the right; a left extension can steal the previous return.
+ last = min(window.end + return_buffer_frames, len(poses) - 1)
+ candidates = np.arange(window.start, last + 1, dtype=np.int64)
+ rotations = np.asarray(
+ [rotation_distance_deg(reference[:3, :3], poses[i, :3, :3]) for i in candidates], dtype=np.float64
+ )
+ translation_gated = slot.axis in {"lateral", "longitudinal", "translation"}
+ if translation_gated:
+ positions = poses[:, :3, 3]
+ span = float(2.0 * np.linalg.norm(positions - positions.mean(axis=0), axis=1).max())
+ translations = np.asarray(
+ [np.linalg.norm(reference[:3, 3] - poses[i, :3, 3]) / max(span, 1e-8) for i in candidates],
+ dtype=np.float64,
+ )
+ best = int(np.argmin(rotations / rotation_threshold_deg + translations / max(translation_threshold, 1e-8)))
+ else:
+ best = int(np.argmin(rotations))
+ error = float(rotations[best])
+ returned = error <= rotation_threshold_deg and (
+ not translation_gated or float(translations[best]) <= translation_threshold
+ )
+ results.append(ReturnCandidate(slot.slot_id, slot.axis, int(candidates[best]), error, returned))
+ return tuple(results)
+
+
+def departure_evidence(poses, window, threshold_deg):
+ errors = [rotation_distance_deg(poses[0, :3, :3], poses[i, :3, :3]) for i in window.indices]
+ best = int(np.argmax(errors))
+ maximum = float(errors[best])
+ return {
+ "departed": maximum >= threshold_deg,
+ "departure_peak_index": window.start + best,
+ "departure_rotation_deg": maximum,
+ }
+
+
+def window_indices(available, start, end, max_count=8):
+ values = np.asarray(available, dtype=np.int64)
+ values = values[(values >= start) & (values <= end)]
+ if len(values) > max_count:
+ values = values[np.asarray(np.round(np.linspace(0, len(values) - 1, max_count)), dtype=np.int64)]
+ return tuple(int(value) for value in values)
+
+
+def spherical_range_field(
+ geometry,
+ indices,
+ anchor_rotation,
+ *,
+ semantic_masks=None,
+ angular_width=128,
+ confidence_quantile=0.5,
+ near_depth_percentile=5.0,
+ far_depth_percentile=85.0,
+ max_points_per_frame=5000,
+):
+ if angular_width < 8 or angular_width % 2:
+ raise ValueError("angular_width must be an even integer >= 8")
+ if not 0 <= confidence_quantile < 1 or not 0 <= near_depth_percentile < far_depth_percentile <= 100:
+ raise ValueError("invalid confidence or depth quantiles")
+ if max_points_per_frame < 1:
+ raise ValueError("max_points_per_frame must be positive")
+ height = angular_width // 2
+ flat = np.full(angular_width * height, np.inf, dtype=np.float64)
+ by_index = geometry.index_to_position
+ anchor_inverse = np.asarray(anchor_rotation, dtype=np.float64).T
+ for index in indices:
+ position = by_index[index]
+ depth = np.asarray(geometry.depths[position], dtype=np.float64)
+ confidence = np.asarray(geometry.confidence[position], dtype=np.float64)
+ valid = np.isfinite(depth) & np.isfinite(confidence) & (depth > 0)
+ if semantic_masks is not None:
+ valid &= semantic_masks[index]
+ if not valid.any():
+ continue
+ threshold = float(np.quantile(confidence[valid], confidence_quantile))
+ valid &= confidence >= threshold
+ low, high = np.percentile(depth[valid], [near_depth_percentile, far_depth_percentile])
+ valid &= (depth >= low) & (depth <= high)
+ ys, xs = np.where(valid)
+ if not len(xs):
+ continue
+ if len(xs) > max_points_per_frame:
+ selected = np.asarray(np.round(np.linspace(0, len(xs) - 1, max_points_per_frame)), dtype=np.int64)
+ ys, xs = ys[selected], xs[selected]
+ pixels = np.stack([xs.astype(np.float64), ys.astype(np.float64), np.ones(len(xs))], axis=0)
+ rays = np.linalg.inv(geometry.intrinsics[position]) @ pixels
+ ranges = np.linalg.norm(rays, axis=0) * depth[ys, xs]
+ directions = anchor_inverse @ geometry.c2w[position, :3, :3] @ rays
+ directions /= np.maximum(np.linalg.norm(directions, axis=0), 1e-12)
+ azimuth = np.arctan2(directions[0], directions[2])
+ elevation = np.arcsin(np.clip(directions[1], -1.0, 1.0))
+ x = np.floor((azimuth + np.pi) * angular_width / (2.0 * np.pi)).astype(np.int64)
+ y = np.floor((elevation + np.pi / 2.0) * height / np.pi).astype(np.int64)
+ bins = np.clip(y, 0, height - 1) * angular_width + np.clip(x, 0, angular_width - 1)
+ np.minimum.at(flat, bins, np.log(np.maximum(ranges, 1e-12)))
+ values = flat.reshape(height, angular_width)
+ values[~np.isfinite(values)] = np.nan
+ return values
+
+
+def compare_range_fields(reference, revisit):
+ ref_valid, rev_valid = np.isfinite(reference), np.isfinite(revisit)
+ common = ref_valid & rev_valid
+ union_bins, common_bins = int((ref_valid | rev_valid).sum()), int(common.sum())
+ if not union_bins or not common_bins:
+ return {"reconstruction_loss": 1.0, "common_bins": common_bins, "union_bins": union_bins}
+ offset = float(np.median(reference[common] - revisit[common]))
+ residual = np.abs(reference[common] - (revisit[common] + offset))
+ similarity = float(np.exp(-residual).sum() / union_bins)
+ similarity = float(np.clip(similarity, 0.0, 1.0))
+ return {"reconstruction_loss": 1.0 - similarity, "common_bins": common_bins, "union_bins": union_bins}
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/metric.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/metric.py
new file mode 100644
index 000000000..8e32f5741
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/metric.py
@@ -0,0 +1,293 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""The three formal Environment scores, sharing DA3 inputs and return selection."""
+
+from dataclasses import asdict
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.aggregation import (
+ aggregate_video_summary,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import (
+ ItemResult,
+ ResultStatus,
+ UnitResult,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+from .protocol import protocol_for_sample
+
+
+class _EnvironmentMetric(Metric):
+ def __init__(self, *, reader=None):
+ self.reader = reader or VideoReader()
+
+ def evaluate(self, sample, context):
+ if sample.get("category") not in (None, "environment"):
+ return [UnitResult("video", status=ResultStatus.NOT_APPLICABLE)]
+ if error := sample.get("environment_preparation_error"):
+ exception = DataInvalidError if error["status"] == "data_invalid" else EvaluationError
+ raise exception(error["message"])
+
+ def load_inputs():
+ from .artifacts import load_poses
+ from .geometry import select_returns
+
+ info = self.reader.probe(sample["video"])
+ protocol = protocol_for_sample(sample, total_frames=info.total_frames)
+ artifacts = sample.get("artifacts") or {}
+ if not artifacts.get("da3"):
+ raise DataInvalidError("missing artifacts.da3 (da3/results.npz)")
+ poses = load_poses(artifacts["da3"], info.total_frames)
+ config = context.metric_config("environment_return_validity")
+ returns = select_returns(
+ poses,
+ protocol,
+ rotation_threshold_deg=float(config.get("rotation_threshold_deg", 30.0)),
+ translation_threshold=float(config.get("translation_threshold", 0.15)),
+ return_buffer_frames=int(config.get("return_buffer_frames", 30)),
+ )
+ return protocol, poses, returns
+
+ protocol, poses, returns = context.get_cached(("environment:inputs", sample["video"]), load_inputs)
+ scores, metadata = self.score(sample, context, protocol, poses, returns)
+ return [
+ UnitResult(
+ "video",
+ unit_type="video",
+ scores=scores,
+ metadata={
+ "video_path": sample["video"],
+ "num_frames": protocol.total_frames,
+ "actions": list(protocol.actions),
+ "phase_transitions": list(protocol.phase_transitions),
+ "tail_hold_transitions": protocol.tail_hold_transitions,
+ "phases": [asdict(phase) for phase in protocol.phases],
+ **metadata,
+ },
+ )
+ ]
+
+ @staticmethod
+ def geometry(sample, context, total_frames):
+ from .artifacts import load_scene_geometry
+
+ path = (sample.get("artifacts") or {}).get("da3_scene")
+ if not path:
+ raise DataInvalidError("missing artifacts.da3_scene (da3/scene_geometry.npz)")
+ return context.get_cached(("environment:geometry", path), lambda: load_scene_geometry(path, total_frames))
+
+ def aggregate_item(self, sample, units):
+ unit = units[0]
+ return ItemResult(
+ sample["item_id"],
+ scores=dict(unit.scores),
+ status=unit.status,
+ error=unit.error,
+ metadata=dict(unit.metadata),
+ num_units=1,
+ )
+
+ def aggregate_summary(self, items):
+ return aggregate_video_summary(items)
+
+
+class EnvironmentVisibleMemoryMetric(_EnvironmentMetric):
+ name = "environment_visible_memory"
+ description = "Mean full-frame DINOv2 similarity at DA3 return frames."
+
+ def __init__(self, *, reader=None, backend=None):
+ super().__init__(reader=reader)
+ self.backend = backend
+
+ def score(self, sample, context, protocol, poses, returns):
+ import numpy as np
+
+ from ..features import get_dino_model
+ from .backends import DinoVisibleFeatures
+
+ config = context.metric_config(self.name)
+ checkpoint = config.get("dino_checkpoint") or context.cache_dir / "metrics" / self.name / "dinov2-large"
+ required = sorted({0, *(value.return_index for value in returns)})
+ frames = self.reader.read_indices(sample["video"], required)
+ backend = self.backend or context.get_resource(
+ "environment:visible_dino",
+ lambda: DinoVisibleFeatures(
+ checkpoint,
+ context.device,
+ int(config.get("batch_size", 8)),
+ model=get_dino_model(context, checkpoint),
+ ),
+ )
+ features = backend.embed([frame.rgb for frame in frames])
+ if len(features) != len(required):
+ raise EvaluationError("DINO returned the wrong number of feature rows")
+ by_index = dict(zip(required, features))
+ reference = np.asarray(by_index[0], dtype=np.float64)
+
+ def similarity(index):
+ return float(np.clip(reference @ np.asarray(by_index[index], dtype=np.float64), 0.0, 1.0))
+
+ slots = [
+ {"slot_id": value.slot_id, "return_index": value.return_index, "similarity": similarity(value.return_index)}
+ for value in returns
+ ]
+ visible = float(np.mean([slot["similarity"] for slot in slots]))
+ return {"score": visible}, {"visible_similarity": visible, "slots": slots}
+
+
+class EnvironmentDepthFidelityMetric(_EnvironmentMetric):
+ name = "environment_depth_fidelity"
+ description = "Pixel-aligned DA3 depth-order Spearman correlation at return frames."
+
+ def score(self, sample, context, protocol, poses, returns):
+ import numpy as np
+ from scipy.stats import spearmanr
+
+ geometry = self.geometry(sample, context, protocol.total_frames)
+ by_index = geometry.index_to_position
+ reference = np.asarray(geometry.depths[by_index[0]], dtype=np.float64).ravel()
+ slots = []
+ for value in returns:
+ sampled = min(by_index, key=lambda index: abs(index - value.return_index))
+ candidate = np.asarray(geometry.depths[by_index[sampled]], dtype=np.float64).ravel()
+ valid = np.isfinite(reference) & np.isfinite(candidate) & (reference > 0) & (candidate > 0)
+ if int(valid.sum()) < 100:
+ raise EvaluationError("depth fidelity requires at least 100 valid pixels")
+ score = float(spearmanr(reference[valid], candidate[valid]).statistic)
+ if not np.isfinite(score):
+ raise EvaluationError("depth fidelity produced a non-finite Spearman value")
+ slots.append(
+ {
+ "slot_id": value.slot_id,
+ "axis": value.axis,
+ "return_status": "returned" if value.returned else "not_revisited",
+ "return_index": value.return_index,
+ "sampled_return_index": sampled,
+ "depth_spearman": score,
+ }
+ )
+ return {"depth_spearman": float(np.mean([slot["depth_spearman"] for slot in slots]))}, {"slots": slots}
+
+
+class EnvironmentSceneReconstructionMetric(_EnvironmentMetric):
+ name = "environment_scene_reconstruction"
+ description = "Pure-rotation spherical reconstruction score (1 - reference loss), higher is better."
+
+ def __init__(self, *, reader=None, semantic_filter=None):
+ super().__init__(reader=reader)
+ self.semantic_filter = semantic_filter
+
+ def score(self, sample, context, protocol, poses, returns):
+ import cv2
+ import numpy as np
+
+ from .backends import DEFAULT_REJECT_LABELS, SegformerMasks
+ from .geometry import compare_range_fields, departure_evidence, spherical_range_field, window_indices
+
+ if any(slot.axis not in {"yaw", "pitch"} for slot in protocol.slots):
+ raise EvaluationError("Scene Reconstruction supports pure-rotation yaw/pitch trajectories only")
+ config = context.metric_config(self.name)
+ geometry = self.geometry(sample, context, protocol.total_frames)
+ available = geometry.frame_indices
+ home_fraction, home_frames = (
+ float(config.get("home_window_fraction", 0.2)),
+ int(config.get("home_window_frames", 8)),
+ )
+ if not 0 < home_fraction <= 1 or home_frames < 1:
+ raise ValueError("invalid reconstruction home window")
+ phase0 = protocol.phases[0]
+ home = window_indices(
+ available, phase0.start, int(round(phase0.start + home_fraction * (phase0.end - phase0.start))), home_frames
+ )
+ radius = int(config.get("revisit_radius_frames", 14))
+ threshold = float(context.metric_config("environment_return_validity").get("rotation_threshold_deg", 30.0))
+ prepared, required = [], set(home)
+ for slot, value in zip(protocol.slots, returns, strict=True):
+ indices = window_indices(
+ available,
+ max(int(available[0]), value.return_index - radius),
+ min(int(available[-1]), value.return_index + radius),
+ home_frames,
+ )
+ departure = departure_evidence(poses, protocol.phases[slot.outbound_phase], threshold)
+ prepared.append((value, indices, departure))
+ required.update(indices)
+ masks = None
+ if config.get("use_semantic_filter", True) and required:
+ backend = self.semantic_filter or context.get_resource(
+ "environment:segformer",
+ lambda: SegformerMasks(
+ config.get("semantic_checkpoint")
+ or context.cache_dir / "metrics" / self.name / "segformer-b0-ade20k",
+ context.device,
+ int(config.get("semantic_batch_size", 4)),
+ config.get("semantic_reject_labels") or DEFAULT_REJECT_LABELS,
+ ),
+ )
+ indices = sorted(required)
+ images = [frame.rgb for frame in self.reader.read_indices(sample["video"], indices)]
+ predicted = backend.keep_masks(images)
+ if len(predicted) != len(indices):
+ raise EvaluationError("SegFormer returned the wrong number of masks")
+ height, width = geometry.depths.shape[1:]
+ masks = {
+ index: cv2.resize(
+ np.asarray(mask, dtype=np.uint8), (width, height), interpolation=cv2.INTER_NEAREST
+ ).astype(bool)
+ for index, mask in zip(indices, predicted)
+ }
+ settings = dict(
+ semantic_masks=masks,
+ angular_width=int(config.get("angular_width", 128)),
+ confidence_quantile=float(config.get("confidence_quantile", 0.5)),
+ near_depth_percentile=float(config.get("near_depth_percentile", 5.0)),
+ far_depth_percentile=float(config.get("far_depth_percentile", 85.0)),
+ max_points_per_frame=int(config.get("max_points_per_frame", 5000)),
+ )
+ reference = spherical_range_field(geometry, home, poses[0, :3, :3], **settings)
+ minimum_bins, require_departure = (
+ int(config.get("minimum_common_bins", 64)),
+ bool(config.get("require_pose_departure", True)),
+ )
+ losses, slots = [], []
+ for value, indices, departure in prepared:
+ revisit = spherical_range_field(geometry, indices, poses[value.return_index, :3, :3], **settings)
+ comparison = compare_range_fields(reference, revisit)
+ usable = (
+ value.returned
+ and (departure["departed"] or not require_departure)
+ and comparison["common_bins"] >= minimum_bins
+ )
+ loss = comparison["reconstruction_loss"] if usable else 1.0
+ losses.append(float(loss))
+ if usable:
+ status = "ok"
+ elif require_departure and not departure["departed"]:
+ status = "not_departed"
+ elif not value.returned:
+ status = "not_revisited"
+ else:
+ status = "insufficient_common_bins"
+ slots.append(
+ {
+ "slot_id": value.slot_id,
+ "axis": value.axis,
+ "status": status,
+ "return_status": "returned" if value.returned else "not_revisited",
+ "return_index": value.return_index,
+ "return_rotation_error_deg": value.rotation_error_deg,
+ "reference_indices": list(home),
+ "revisit_indices": list(indices),
+ **departure,
+ "reconstruction_score": 1.0 - loss,
+ "common_bins": comparison["common_bins"],
+ "union_bins": comparison["union_bins"],
+ }
+ )
+ return {"reconstruction_score": 1.0 - float(np.mean(losses))}, {"slots": slots}
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/preparation.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/preparation.py
new file mode 100644
index 000000000..79e1c1685
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/preparation.py
@@ -0,0 +1,67 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Resolve the Environment case layout without inferring missing trajectory data."""
+
+import json
+from pathlib import Path
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+from .protocol import protocol_fields
+
+
+def prepare_for_evaluation(samples: list[dict]) -> None:
+ """Read trajectory.json and register DA3 paths; inference is a separate step."""
+ for sample in samples:
+ if sample.get("category") not in (None, "environment"):
+ continue
+ sample.pop("environment_preparation_error", None)
+ try:
+ case_dir = Path(sample["video"]).parent
+ annotations = dict(sample.get("annotations") or {})
+ if "environment_protocol" not in annotations:
+ annotations["environment_protocol"] = json.loads(
+ (case_dir / "trajectory.json").read_text(encoding="utf-8")
+ )
+ annotations["environment_protocol"] = protocol_fields(annotations["environment_protocol"])
+ artifacts = dict(sample.get("artifacts") or {})
+ for key, filename in (("da3", "results.npz"), ("da3_scene", "scene_geometry.npz")):
+ artifacts.setdefault(key, str((case_dir / "da3" / filename).resolve()))
+ sample["annotations"] = annotations
+ sample["artifacts"] = artifacts
+ except (DataInvalidError, OSError, ValueError, KeyError, TypeError) as error:
+ sample["environment_preparation_error"] = {
+ "status": "data_invalid",
+ "message": f"invalid Environment trajectory: {error}",
+ }
+
+
+def prepare_artifacts(samples: list[dict], context, metric_names) -> None:
+ """Run before a shard loads scoring models, then release the DA3 subprocess."""
+ from ...preparation.da3 import ensure_artifacts
+
+ require_scene = bool({"environment_depth_fidelity", "environment_scene_reconstruction"}.intersection(metric_names))
+ weight_metric = next(name for name in metric_names if name.startswith("environment_"))
+ eligible = [
+ sample
+ for sample in samples
+ if sample.get("category") in (None, "environment") and not sample.get("environment_preparation_error")
+ ]
+ if eligible:
+ print(f"[{context.device}][DA3] 0/{len(eligible)}", flush=True)
+ for index, sample in enumerate(eligible, 1):
+ try:
+ ensure_artifacts(sample, context, weight_metric=weight_metric, require_scene=require_scene)
+ except Exception as error:
+ sample["environment_preparation_error"] = {
+ "status": "eval_failed",
+ "message": f"DA3 preparation failed: {type(error).__name__}: {error}",
+ }
+ print(
+ f"[{context.device}][DA3] {index}/{len(eligible)} failed: {sample['item_id']}: "
+ f"{sample['environment_preparation_error']['message']}",
+ flush=True,
+ )
+ else:
+ print(f"[{context.device}][DA3] {index}/{len(eligible)} ready: {sample['item_id']}", flush=True)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/protocol.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/protocol.py
new file mode 100644
index 000000000..eba0c6217
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/environment/protocol.py
@@ -0,0 +1,107 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Map explicit trajectory phases to video frames using the reference protocol."""
+
+from dataclasses import dataclass
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+INVERSE_ACTION = {
+ "yaw_left": "yaw_right",
+ "yaw_right": "yaw_left",
+ "pitch_up": "pitch_down",
+ "pitch_down": "pitch_up",
+ "move_left": "move_right",
+ "move_right": "move_left",
+ "move_forward": "move_backward",
+ "move_backward": "move_forward",
+}
+
+
+@dataclass(frozen=True)
+class PhaseWindow:
+ phase_id: int
+ action: str
+ axis: str
+ start: int
+ end: int
+
+ @property
+ def indices(self) -> range:
+ return range(self.start, self.end + 1)
+
+
+@dataclass(frozen=True)
+class RevisitSlot:
+ slot_id: int
+ outbound_phase: int
+ return_phase: int
+ axis: str
+
+
+@dataclass(frozen=True)
+class EnvironmentProtocol:
+ condition_id: str
+ total_frames: int
+ actions: tuple[str, ...]
+ phase_transitions: tuple[int, ...]
+ tail_hold_transitions: int
+ phases: tuple[PhaseWindow, ...]
+ slots: tuple[RevisitSlot, ...]
+
+
+def protocol_fields(value: dict) -> dict:
+ """Read the three scoring fields; do not infer timing from a directory name."""
+ if not isinstance(value, dict):
+ raise DataInvalidError("Environment trajectory must be a JSON object")
+ actions = value.get("actions")
+ transitions = value.get("phase_transitions")
+ tail = value.get("tail_hold_transitions", 0)
+ if (
+ not isinstance(actions, list)
+ or not actions
+ or len(actions) % 2
+ or any(not isinstance(action, str) or action not in INVERSE_ACTION for action in actions)
+ ):
+ raise DataInvalidError("trajectory.actions must contain supported outbound/return action pairs")
+ if any(INVERSE_ACTION[actions[i]] != actions[i + 1] for i in range(0, len(actions), 2)):
+ raise DataInvalidError("each Environment action pair must be inverse")
+ if (
+ not isinstance(transitions, list)
+ or len(transitions) != len(actions)
+ or any(type(count) is not int or count <= 0 for count in transitions)
+ ):
+ raise DataInvalidError("trajectory.phase_transitions must contain one positive integer per action")
+ if type(tail) is not int or tail < 0:
+ raise DataInvalidError("trajectory.tail_hold_transitions must be a non-negative integer")
+ return {"actions": list(actions), "phase_transitions": list(transitions), "tail_hold_transitions": tail}
+
+
+def protocol_for_sample(sample: dict, *, total_frames: int) -> EnvironmentProtocol:
+ fields = protocol_fields((sample.get("annotations") or {}).get("environment_protocol"))
+ if total_frames < 3:
+ raise DataInvalidError("Environment video requires at least three frames")
+ actions = tuple(fields["actions"])
+ transitions = tuple(fields["phase_transitions"])
+ tail = fields["tail_hold_transitions"]
+ source_last = sum(transitions) + tail
+ phases = []
+ cursor = 0
+ for index, (action, count) in enumerate(zip(actions, transitions)):
+ start = int(round(cursor * (total_frames - 1) / source_last))
+ cursor += count
+ end = int(round(cursor * (total_frames - 1) / source_last))
+ if end <= start:
+ raise DataInvalidError(f"phase {index} collapses in decoded frame space")
+ if action in ("move_left", "move_right"):
+ axis = "lateral"
+ elif action in ("move_forward", "move_backward"):
+ axis = "longitudinal"
+ else:
+ axis = action.split("_", 1)[0]
+ phases.append(PhaseWindow(index, action, axis, start, end))
+ slots = tuple(RevisitSlot(i // 2 + 1, i, i + 1, phases[i].axis) for i in range(0, len(phases), 2))
+ return EnvironmentProtocol(
+ str(sample.get("condition_id") or ""), total_frames, actions, transitions, tail, tuple(phases), slots
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/features.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/features.py
new file mode 100644
index 000000000..24e3a544a
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/features.py
@@ -0,0 +1,130 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Masked DINOv2 appearance features shared by human and object protocols."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+from .human.target_association import normalized_embedding
+
+
+def get_dino_model(context: EvaluationContext, checkpoint: str | Path) -> Any:
+ """Share a local CLS backbone while keeping each protocol's preprocessing separate."""
+ from worldfoundry.base_models.perception_core.general_perception.dino_embeddings import load_dinov2_feature_model
+
+ checkpoint = Path(checkpoint).expanduser().resolve()
+ return context.get_resource(
+ ("dinov2-cls", checkpoint, context.device),
+ lambda: load_dinov2_feature_model(checkpoint, device=context.device),
+ )
+
+
+class MaskedDinoV2Features:
+ """Batched masked-crop DINOv2-Large CLS embeddings."""
+
+ def __init__(
+ self,
+ *,
+ model: Any,
+ device: str,
+ input_size: int = 518,
+ batch_size: int = 16,
+ ) -> None:
+ if batch_size < 1:
+ raise ValueError("DINO batch_size must be positive")
+ try:
+ import torch
+ import torchvision.transforms as transforms
+ except ImportError as error:
+ raise EvaluationError(
+ "Masked DINOv2 features require torch and torchvision; install the WorldMemBench metric requirements"
+ ) from error
+ self.model = model
+ self.transform = transforms.Compose(
+ [
+ transforms.ToTensor(),
+ transforms.Resize((input_size, input_size), antialias=True),
+ transforms.Normalize(
+ mean=[0.485, 0.456, 0.406],
+ std=[0.229, 0.224, 0.225],
+ ),
+ ]
+ )
+ self.device = device
+ self.torch = torch
+ self.effective_batch_size = batch_size
+
+ @staticmethod
+ def _masked_crop(
+ frame_rgb: np.ndarray,
+ mask: np.ndarray,
+ ) -> np.ndarray | None:
+ if mask.shape != frame_rgb.shape[:2] or not mask.any():
+ return None
+ y_coordinates, x_coordinates = np.where(mask)
+ x1, x2 = int(x_coordinates.min()), int(x_coordinates.max()) + 1
+ y1, y2 = int(y_coordinates.min()), int(y_coordinates.max()) + 1
+ crop = frame_rgb[y1:y2, x1:x2].copy()
+ crop_mask = mask[y1:y2, x1:x2]
+ crop[~crop_mask] = 0
+ return crop
+
+ @staticmethod
+ def _is_out_of_memory(error: RuntimeError) -> bool:
+ return "out of memory" in str(error).lower()
+
+ def embed_batch(
+ self,
+ samples: Sequence[tuple[np.ndarray, np.ndarray]],
+ ) -> list[np.ndarray | None]:
+ results: list[np.ndarray | None] = [None] * len(samples)
+ position = 0
+ while position < len(samples):
+ chunk_size = min(
+ self.effective_batch_size,
+ len(samples) - position,
+ )
+ chunk = samples[position : position + chunk_size]
+ tensors = []
+ result_indices = []
+ for offset, (frame_rgb, mask) in enumerate(chunk):
+ crop = self._masked_crop(frame_rgb, mask)
+ if crop is None:
+ continue
+ tensors.append(self.transform(crop))
+ result_indices.append(position + offset)
+ if not tensors:
+ position += chunk_size
+ continue
+ try:
+ tensor_batch = self.torch.stack(tensors).to(self.device)
+ with self.torch.inference_mode():
+ vectors = self.model(tensor_batch)
+ vectors = vectors.detach().float().cpu().numpy()
+ except RuntimeError as error:
+ if not self._is_out_of_memory(error) or self.effective_batch_size == 1:
+ raise
+ self.effective_batch_size = max(
+ 1,
+ self.effective_batch_size // 2,
+ )
+ if self.torch.cuda.is_available():
+ self.torch.cuda.empty_cache()
+ continue
+ for result_index, vector in zip(result_indices, vectors):
+ results[result_index] = normalized_embedding(vector)
+ position += chunk_size
+ return results
+
+ def embed(self, frame_rgb: np.ndarray, mask: np.ndarray) -> np.ndarray | None:
+ """Extract one masked crop with the same preprocessing as a batch."""
+ return self.embed_batch([(frame_rgb, mask)])[0]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/__init__.py
new file mode 100644
index 000000000..c4a035fcd
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/__init__.py
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""General Video Quality metrics; model backends are imported lazily."""
+
+from .aesthetic_quality import AestheticQualityMetric
+from .imaging_quality import ImagingQualityMetric
+
+__all__ = ["AestheticQualityMetric", "ImagingQualityMetric"]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/aesthetic.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/aesthetic.py
new file mode 100644
index 000000000..d2f6ba4e7
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/aesthetic.py
@@ -0,0 +1,118 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""OpenAI CLIP + LAION aesthetic predictor backend."""
+
+from __future__ import annotations
+
+import math
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+CLIP_MEAN = (0.48145466, 0.4578275, 0.40821073)
+CLIP_STD = (0.26862954, 0.26130258, 0.27577711)
+
+
+def preprocess_clip_rgb(
+ frames: list[np.ndarray],
+ image_size: int = 224,
+) -> Any:
+ """Apply VBench's tensor CLIP transform to RGB uint8 frames."""
+
+ if image_size <= 0:
+ raise ValueError("image_size must be positive")
+ if not frames:
+ import torch
+
+ return torch.empty((0, 3, image_size, image_size), dtype=torch.float32)
+
+ import torch
+ from torchvision.transforms import CenterCrop, InterpolationMode, Resize
+
+ resize = Resize(
+ image_size,
+ interpolation=InterpolationMode.BICUBIC,
+ antialias=False,
+ )
+ center_crop = CenterCrop(image_size)
+ tensors = []
+ for frame in frames:
+ if frame.ndim != 3 or frame.shape[2] != 3:
+ raise ValueError(f"expected HxWx3 RGB image, got shape {frame.shape}")
+ tensor = torch.from_numpy(np.ascontiguousarray(frame)).permute(2, 0, 1).to(dtype=torch.float32)
+ tensors.append(center_crop(resize(tensor)))
+
+ batch = torch.stack(tensors).to(dtype=torch.float32).div_(255.0)
+ mean = torch.tensor(CLIP_MEAN, dtype=batch.dtype).view(1, 3, 1, 1)
+ std = torch.tensor(CLIP_STD, dtype=batch.dtype).view(1, 3, 1, 1)
+ return (batch - mean) / std
+
+
+class LaionAestheticBackend:
+ """Fixed VBench-compatible ViT-L/14 aesthetic scoring backend."""
+
+ def __init__(
+ self,
+ *,
+ clip_checkpoint: str | Path,
+ aesthetic_checkpoint: str | Path,
+ device: str,
+ batch_size: int = 32,
+ ) -> None:
+ self.clip_checkpoint = Path(clip_checkpoint).expanduser().resolve()
+ self.aesthetic_checkpoint = Path(aesthetic_checkpoint).expanduser().resolve()
+ if not self.clip_checkpoint.is_file():
+ raise EvaluationError(f"clip checkpoint not found: {self.clip_checkpoint}")
+ if not self.aesthetic_checkpoint.is_file():
+ raise EvaluationError(f"aesthetic checkpoint not found: {self.aesthetic_checkpoint}")
+ if batch_size <= 0:
+ raise ValueError("batch_size must be positive")
+
+ self.device = device
+ self.batch_size = batch_size
+ try:
+ from worldfoundry.base_models.perception_core.general_perception import openai_clip as clip
+ from worldfoundry.evaluation.tasks.metrics._shared.aesthetic import load_laion_aesthetic_linear_head
+ except ImportError as error:
+ raise EvaluationError(
+ "aesthetic_quality requires torch and torchvision; install the WorldMemBench metric requirements"
+ ) from error
+
+ self.clip_model, _ = clip.load(
+ str(self.clip_checkpoint),
+ device=device,
+ jit=False,
+ )
+ self.clip_model.eval()
+
+ self.aesthetic_model = load_laion_aesthetic_linear_head(self.aesthetic_checkpoint).to(device)
+
+ def score(self, frames: list[np.ndarray]) -> list[float]:
+ if not frames:
+ return []
+
+ import torch
+ import torch.nn.functional as functional
+
+ outputs: list[float] = []
+ for start in range(0, len(frames), self.batch_size):
+ batch = preprocess_clip_rgb(frames[start : start + self.batch_size]).to(self.device)
+ with torch.inference_mode():
+ features: Any = self.clip_model.encode_image(batch)
+ features = functional.normalize(
+ features.to(torch.float32),
+ dim=-1,
+ p=2,
+ )
+ raw: Any = self.aesthetic_model(features).squeeze(dim=-1)
+ outputs.extend(float(value) for value in raw.detach().float().cpu().reshape(-1).tolist())
+
+ if len(outputs) != len(frames):
+ raise EvaluationError(f"LAION aesthetic backend returned {len(outputs)} scores for {len(frames)} frames")
+ if not all(math.isfinite(value) for value in outputs):
+ raise EvaluationError("LAION aesthetic backend returned a non-finite score")
+ return outputs
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/aesthetic_quality.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/aesthetic_quality.py
new file mode 100644
index 000000000..5b0c6a9f2
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/aesthetic_quality.py
@@ -0,0 +1,43 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""OpenAI CLIP ViT-L/14 + LAION aesthetic quality."""
+
+from pathlib import Path
+
+from .common import FrameQualityMetric
+
+
+class AestheticQualityMetric(FrameQualityMetric):
+ name = "aesthetic_quality"
+ description = "CLIP ViT-L/14 + LAION aesthetic quality (stride 4, raw / 10)."
+ raw_key = "raw_score"
+ normalizer = 10.0
+
+ def _backend(self, context):
+ from .aesthetic import LaionAestheticBackend
+
+ config = context.metric_config(self.name)
+ clip_path = (
+ Path(config.get("clip_checkpoint") or (context.cache_dir / "metrics" / self.name / "ViT-L-14.pt"))
+ .expanduser()
+ .resolve()
+ )
+ aesthetic_path = (
+ Path(
+ config.get("aesthetic_checkpoint")
+ or (context.cache_dir / "metrics" / self.name / "sa_0_4_vit_l_14_linear.pth")
+ )
+ .expanduser()
+ .resolve()
+ )
+ batch_size = int(config.get("batch_size", 32))
+ return context.get_resource(
+ f"laion_aesthetic:{clip_path}:{aesthetic_path}:{context.device}:{batch_size}",
+ lambda: LaionAestheticBackend(
+ clip_checkpoint=clip_path,
+ aesthetic_checkpoint=aesthetic_path,
+ device=context.device,
+ batch_size=batch_size,
+ ),
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/common.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/common.py
new file mode 100644
index 000000000..faf3320a6
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/common.py
@@ -0,0 +1,72 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Reference stride-4 frame scoring and equal-weight video aggregation."""
+
+import math
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.aggregation import (
+ aggregate_video_summary,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import ItemResult, UnitResult
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import (
+ VideoReader,
+ fixed_stride_indices,
+)
+
+
+class FrameQualityMetric(Metric):
+ raw_key: str
+ normalizer: float
+ unit_type = "frame"
+
+ def __init__(self, *, backend=None, reader=None) -> None:
+ self.backend = backend
+ self.reader = reader or VideoReader()
+
+ def _backend(self, context: EvaluationContext):
+ raise NotImplementedError
+
+ def evaluate(self, sample: dict, context: EvaluationContext) -> list[UnitResult]:
+ path = sample["video"]
+ info = self.reader.probe(path)
+ indices = fixed_stride_indices(info.total_frames, 4)
+ decoded = context.get_cached(
+ ("rgb_frames", path, tuple(indices)),
+ lambda: self.reader.read_indices(path, indices),
+ )
+ backend = self.backend if self.backend is not None else self._backend(context)
+ raw_scores = [float(value) for value in backend.score([frame.rgb for frame in decoded])]
+ if len(raw_scores) != len(decoded):
+ raise EvaluationError(f"{self.name} backend returned {len(raw_scores)} scores for {len(decoded)} frames")
+ if not all(math.isfinite(value) for value in raw_scores):
+ raise EvaluationError(f"{self.name} backend returned a non-finite score")
+ return [
+ UnitResult(
+ unit_id=f"frame_{frame.frame_idx:08d}",
+ unit_type="frame",
+ scores={"score": max(0.0, min(1.0, raw / self.normalizer)), self.raw_key: raw},
+ metadata={"frame_idx": frame.frame_idx, "video_path": path},
+ )
+ for frame, raw in zip(decoded, raw_scores)
+ ]
+
+ def aggregate_item(self, sample: dict, units: list[UnitResult]) -> ItemResult:
+ if not units:
+ raise EvaluationError(f"{self.name} produced no frame units")
+ identity = {key: sample.get(key) for key in ("item_id", "model_id", "category", "sample_id", "condition_id")}
+ non_scored = [unit for unit in units if not unit.status.is_scored]
+ if non_scored:
+ first = non_scored[0]
+ return ItemResult(**identity, num_units=len(units), status=first.status, error=first.error)
+ return ItemResult(
+ **identity,
+ scores={key: sum(unit.scores[key] for unit in units) / len(units) for key in ("score", self.raw_key)},
+ num_units=len(units),
+ metadata={"sampled_frame_indices": [unit.metadata["frame_idx"] for unit in units]},
+ )
+
+ aggregate_summary = staticmethod(aggregate_video_summary)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/imaging_quality.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/imaging_quality.py
new file mode 100644
index 000000000..e6468c64d
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/imaging_quality.py
@@ -0,0 +1,32 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""MUSIQ-SPAQ imaging quality, unchanged from the reference calculation."""
+
+from pathlib import Path
+
+from .common import FrameQualityMetric
+
+
+class ImagingQualityMetric(FrameQualityMetric):
+ name = "imaging_quality"
+ description = "MUSIQ-SPAQ imaging quality (stride 4, MOS / 100)."
+ raw_key = "raw_mos"
+ normalizer = 100.0
+
+ def _backend(self, context):
+ from .musiq import MusiqSpaqBackend
+
+ config = context.metric_config(self.name)
+ checkpoint = (
+ Path(
+ config.get("checkpoint") or (context.cache_dir / "metrics" / self.name / "musiq_spaq_ckpt-358bb6af.pth")
+ )
+ .expanduser()
+ .resolve()
+ )
+ batch_size = int(config.get("batch_size", 8))
+ return context.get_resource(
+ f"musiq_spaq:{checkpoint}:{context.device}:{batch_size}",
+ lambda: MusiqSpaqBackend(checkpoint=checkpoint, device=context.device, batch_size=batch_size),
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/musiq.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/musiq.py
new file mode 100644
index 000000000..bdbc3cb39
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/general_video_quality/musiq.py
@@ -0,0 +1,86 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Fixed MUSIQ-SPAQ backend with VBench-compatible longer preprocessing."""
+
+from __future__ import annotations
+
+import math
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+
+def resize_longer_rgb(rgb: np.ndarray, max_side: int = 512) -> np.ndarray:
+ if rgb.ndim != 3 or rgb.shape[2] != 3:
+ raise ValueError(f"expected HxWx3 RGB image, got shape {rgb.shape}")
+ height, width = rgb.shape[:2]
+ longest = max(height, width)
+ if longest <= max_side:
+ return rgb
+ import cv2
+
+ scale = max_side / float(longest)
+ resized_height = max(1, int(scale * height))
+ resized_width = max(1, int(scale * width))
+ return cv2.resize(
+ rgb,
+ (resized_width, resized_height),
+ interpolation=cv2.INTER_LINEAR,
+ )
+
+
+class MusiqSpaqBackend:
+ def __init__(
+ self,
+ *,
+ checkpoint: str | Path,
+ device: str,
+ batch_size: int = 8,
+ ) -> None:
+ self.checkpoint = Path(checkpoint).expanduser().resolve()
+ if not self.checkpoint.is_file():
+ raise EvaluationError(f"MUSIQ-SPAQ checkpoint not found: {self.checkpoint}")
+ if batch_size <= 0:
+ raise ValueError("batch_size must be positive")
+ self.device = device
+ self.batch_size = batch_size
+ try:
+ from pyiqa.archs.musiq_arch import MUSIQ
+ except ImportError as error:
+ raise EvaluationError(
+ "imaging_quality requires pyiqa; install the WorldMemBench metric requirements"
+ ) from error
+ self.model = MUSIQ(pretrained_model_path=str(self.checkpoint))
+ self.model.to(device)
+ self.model.eval()
+
+ def score(self, frames: list[np.ndarray]) -> list[float]:
+ if not frames:
+ return []
+ import torch
+
+ outputs: list[float] = []
+ for start in range(0, len(frames), self.batch_size):
+ resized = [
+ np.ascontiguousarray(resize_longer_rgb(frame)) for frame in frames[start : start + self.batch_size]
+ ]
+ tensor = (
+ torch.from_numpy(np.stack(resized))
+ .permute(0, 3, 1, 2)
+ .contiguous()
+ .to(device=self.device, dtype=torch.float32)
+ / 255.0
+ )
+ with torch.inference_mode():
+ raw: Any = self.model(tensor)
+ values = raw.detach().float().cpu().reshape(-1).tolist()
+ outputs.extend(float(value) for value in values)
+ if len(outputs) != len(frames):
+ raise EvaluationError(f"MUSIQ returned {len(outputs)} scores for {len(frames)} frames")
+ if not all(math.isfinite(value) for value in outputs):
+ raise EvaluationError("MUSIQ returned a non-finite score")
+ return outputs
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/__init__.py
new file mode 100644
index 000000000..31d888ccc
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Human Entity Memory metrics."""
+
+from .metric import HumanAppearanceMetric, HumanIdentityMetric
+
+__all__ = ["HumanAppearanceMetric", "HumanIdentityMetric"]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/appearance_features.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/appearance_features.py
new file mode 100644
index 000000000..357397399
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/appearance_features.py
@@ -0,0 +1,183 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Phase-local body tracking and masked DINOv2 appearance scoring."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Protocol
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+from .person_mask_tracker import MaskPropagator
+from .schemas import (
+ AppearanceAnalysis,
+ AppearanceTargetResult,
+ BoundingBox,
+ IdentityAnalysis,
+)
+from .target_association import (
+ cosine_matrix,
+ normalized_embedding,
+)
+
+
+class AppearanceBackend(Protocol):
+ def extract(
+ self,
+ identity: IdentityAnalysis,
+ ) -> dict[int, dict[int, np.ndarray]]: ...
+
+
+class AppearanceFeatureExtractor(Protocol):
+ def embed_batch(
+ self,
+ samples: Sequence[tuple[np.ndarray, np.ndarray]],
+ ) -> list[np.ndarray | None]: ...
+
+
+class YoloSamDinoAppearanceBackend:
+ def __init__(
+ self,
+ *,
+ mask_propagator: MaskPropagator,
+ feature_extractor: AppearanceFeatureExtractor,
+ ) -> None:
+ self.mask_propagator = mask_propagator
+ self.feature_extractor = feature_extractor
+
+ def _target_phase_features(
+ self,
+ identity: IdentityAnalysis,
+ target_id: int,
+ phase_indices: Sequence[int],
+ person_assignments: dict[int, dict[int, BoundingBox]],
+ ) -> dict[int, dict[int, np.ndarray]]:
+ if not phase_indices:
+ return {}
+ frames = [identity.decoded_frames[index] for index in phase_indices]
+ target_prompts: list[tuple[int, BoundingBox]] = []
+ for local_index, frame_index in enumerate(phase_indices):
+ person_box = person_assignments.get(frame_index, {}).get(target_id)
+ if person_box is not None:
+ target_prompts.append((local_index, person_box))
+ prompts = {target_id: target_prompts} if target_prompts else {}
+ # Appearance keeps only frames with a direct body-box prompt below.
+ # Propagated masks cannot replace those direct masks and are discarded.
+ local_masks = self.mask_propagator.track(
+ frames,
+ prompts,
+ propagate=False,
+ )
+ features: dict[int, dict[int, np.ndarray]] = {}
+ sample_keys: list[tuple[int, int]] = []
+ samples: list[tuple[np.ndarray, np.ndarray]] = []
+ for tracked_target_id, target_masks in sorted(local_masks.items()):
+ for local_index, mask in sorted(target_masks.items()):
+ if local_index < 0 or local_index >= len(phase_indices):
+ raise EvaluationError("SAM2 returned an out-of-range phase frame")
+ frame_index = phase_indices[local_index]
+ # A propagated mask is valid only while the body association
+ # confirms that the tracked mask still overlaps a person.
+ if tracked_target_id not in person_assignments.get(frame_index, {}):
+ continue
+ sample_keys.append((tracked_target_id, frame_index))
+ samples.append(
+ (
+ identity.decoded_frames[frame_index],
+ np.asarray(mask, dtype=bool),
+ )
+ )
+ embeddings = self.feature_extractor.embed_batch(samples)
+ if len(embeddings) != len(sample_keys):
+ raise EvaluationError("DINO batch output count does not match its input count")
+ for (tracked_target_id, frame_index), embedding in zip(
+ sample_keys,
+ embeddings,
+ ):
+ if embedding is not None:
+ features.setdefault(tracked_target_id, {})[frame_index] = normalized_embedding(embedding)
+ return features
+
+ def extract(self, identity: IdentityAnalysis) -> dict[int, dict[int, np.ndarray]]:
+ person_assignments = identity.body_assignments
+ merged: dict[int, dict[int, np.ndarray]] = {}
+ for phase in identity.appearance_target_phases:
+ if not phase.is_scorable:
+ continue
+ visible = self._target_phase_features(
+ identity,
+ phase.target_id,
+ phase.visible_indices,
+ person_assignments,
+ )
+ returned = self._target_phase_features(
+ identity,
+ phase.target_id,
+ phase.return_indices,
+ person_assignments,
+ )
+ for source in (visible, returned):
+ for target_id, frame_features in source.items():
+ merged.setdefault(target_id, {}).update(frame_features)
+ return merged
+
+
+def analyze_appearance(
+ identity: IdentityAnalysis,
+ backend: AppearanceBackend,
+) -> AppearanceAnalysis:
+ features = backend.extract(identity)
+ results: list[AppearanceTargetResult] = []
+ for phase in identity.appearance_target_phases:
+ target_id = phase.target_id
+ target_features = features.get(target_id, {})
+ references = [target_features[index] for index in phase.visible_indices if index in target_features]
+ frame_scores: dict[int, float] = {}
+ n_return_features = 0
+ for frame_index in phase.return_indices:
+ candidate = target_features.get(frame_index)
+ if candidate is None:
+ frame_scores[frame_index] = 0.0
+ continue
+ n_return_features += 1
+ if not references or not phase.is_scorable:
+ frame_scores[frame_index] = 0.0
+ continue
+ similarities = cosine_matrix(references, [candidate])[:, 0]
+ frame_scores[frame_index] = float(np.clip(similarities.max(), 0.0, 1.0))
+ return_frame_count = len(phase.return_indices)
+ score = sum(frame_scores.values()) / n_return_features if n_return_features else 0.0
+ coverage = n_return_features / return_frame_count if return_frame_count else 0.0
+ if not phase.is_scorable:
+ score = 0.0
+ revisit_scores: list[float] = []
+ revisit_coverages: list[float] = []
+ n_revisit_features: list[int] = []
+ for revisit_indices in phase.revisit_indices:
+ feature_indices = [index for index in revisit_indices if index in target_features]
+ n_features = len(feature_indices)
+ n_revisit_features.append(n_features)
+ revisit_coverages.append(n_features / len(revisit_indices) if revisit_indices else 0.0)
+ if n_features and phase.is_scorable:
+ revisit_scores.append(sum(frame_scores[index] for index in feature_indices) / n_features)
+ else:
+ revisit_scores.append(0.0)
+ results.append(
+ AppearanceTargetResult(
+ target_id=target_id,
+ phase=phase,
+ frame_scores=frame_scores,
+ score=score,
+ reappearance_coverage=coverage,
+ n_reference_features=len(references),
+ n_return_features=n_return_features,
+ revisit_scores=tuple(revisit_scores),
+ revisit_coverages=tuple(revisit_coverages),
+ n_revisit_features=tuple(n_revisit_features),
+ )
+ )
+ return AppearanceAnalysis(target_results=tuple(results))
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/body_tracking.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/body_tracking.py
new file mode 100644
index 000000000..0ff2515e3
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/body_tracking.py
@@ -0,0 +1,486 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Target-specific body phases with frozen-face reacquisition and local SAM2."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Protocol
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.revisit_protocol import (
+ MAX_SHORT_MISSED_FRAMES,
+ MIN_REAPPEAR_MATCH_RUN,
+ REVISIT_BOUNDARY_BUFFER_FRAMES,
+ _close_short_internal_gaps,
+ _merge_nearby_runs,
+ _true_runs,
+ infer_target_phase,
+ phase_from_revisit_runs,
+)
+
+from .face_identity import (
+ _frame_matches,
+ build_frozen_reference_libraries,
+)
+from .person_mask_tracker import MaskPropagator, PersonDetector
+from .schemas import (
+ BodyTrackAnalysis,
+ BoundingBox,
+ FaceObservation,
+ FaceTrackAnalysis,
+ HumanEntityProtocol,
+ TargetPhase,
+)
+from .target_association import assign_faces_to_person_boxes, hungarian_matches
+
+BODY_MASK_BOX_IOU_THRESHOLD = 0.1
+BODY_REACQUIRE_PROMPT_STRIDE = 8
+BODY_REACQUISITION_FACE_THRESHOLD = 0.35
+
+
+class BodyTrackerBackend(Protocol):
+ def analyze(
+ self,
+ protocol: HumanEntityProtocol,
+ decoded_frames: dict[int, np.ndarray],
+ *,
+ max_revisit_gap_frames: int,
+ revisit_boundary_buffer_frames: int = REVISIT_BOUNDARY_BUFFER_FRAMES,
+ face_tracks: FaceTrackAnalysis | None = None,
+ face_reacquisition_threshold: float = BODY_REACQUISITION_FACE_THRESHOLD,
+ ) -> BodyTrackAnalysis: ...
+
+
+def _mask_bbox(mask: np.ndarray) -> BoundingBox | None:
+ y_coordinates, x_coordinates = np.where(mask)
+ if not len(x_coordinates):
+ return None
+ return BoundingBox(
+ x1=float(x_coordinates.min()),
+ y1=float(y_coordinates.min()),
+ x2=float(x_coordinates.max() + 1),
+ y2=float(y_coordinates.max() + 1),
+ )
+
+
+def _box_iou(first: BoundingBox, second: BoundingBox) -> float:
+ x1 = max(first.x1, second.x1)
+ y1 = max(first.y1, second.y1)
+ x2 = min(first.x2, second.x2)
+ y2 = min(first.y2, second.y2)
+ intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
+ union = first.area + second.area - intersection
+ return intersection / union if union > 0.0 else 0.0
+
+
+def _initial_phase(
+ *,
+ target_id: int,
+ total_frames: int,
+ assignments: dict[int, dict[int, BoundingBox]],
+) -> TargetPhase:
+ presence = [
+ frame_index == 0 or target_id in assignments.get(frame_index, {}) for frame_index in range(total_frames)
+ ]
+ smoothed = _close_short_internal_gaps(
+ presence,
+ max_gap=MAX_SHORT_MISSED_FRAMES,
+ )
+ initial_runs = _true_runs(smoothed)
+ visible_end = initial_runs[0][1]
+ visible = tuple(range(visible_end + 1))
+ if visible_end == total_frames - 1:
+ return TargetPhase(
+ target_id=target_id,
+ visible_indices=visible,
+ departure_indices=(),
+ return_indices=(),
+ status="not_departed",
+ )
+ return TargetPhase(
+ target_id=target_id,
+ visible_indices=visible,
+ departure_indices=tuple(range(visible_end + 1, total_frames)),
+ return_indices=(),
+ status="not_returned",
+ )
+
+
+def _stable_face_runs(
+ *,
+ target_id: int,
+ visible_end: int,
+ total_frames: int,
+ matches_by_frame: dict[int, dict[int, FaceObservation]],
+ max_revisit_gap_frames: int,
+) -> list[tuple[int, int]]:
+ presence = [target_id in matches_by_frame.get(frame_index, {}) for frame_index in range(total_frames)]
+ runs = [
+ (start, end)
+ for start, end in _true_runs(presence, start=visible_end + 1)
+ if end - start + 1 >= MIN_REAPPEAR_MATCH_RUN
+ ]
+ runs = _merge_nearby_runs(
+ runs,
+ max_gap=max_revisit_gap_frames,
+ )
+ return runs
+
+
+def _segment_bounds(
+ runs: Sequence[tuple[int, int]],
+ *,
+ post_start: int,
+ final_frame: int,
+) -> list[tuple[int, int]]:
+ bounds = []
+ for index, (start, end) in enumerate(runs):
+ segment_start = post_start if index == 0 else (runs[index - 1][1] + start) // 2 + 1
+ segment_end = final_frame if index == len(runs) - 1 else (end + runs[index + 1][0]) // 2
+ bounds.append((segment_start, segment_end))
+ return bounds
+
+
+def _sparse_prompts(
+ assignments: dict[int, BoundingBox],
+ *,
+ start: int,
+ end: int,
+) -> list[tuple[int, BoundingBox]]:
+ frames = sorted(frame_index for frame_index in assignments if start <= frame_index <= end)
+ if not frames:
+ return []
+ selected = [frames[0]]
+ for frame_index in frames[1:-1]:
+ if frame_index - selected[-1] >= BODY_REACQUIRE_PROMPT_STRIDE:
+ selected.append(frame_index)
+ if frames[-1] != selected[-1]:
+ selected.append(frames[-1])
+ return [(frame_index, assignments[frame_index]) for frame_index in selected]
+
+
+def _anchored_component(
+ candidate_frames: set[int],
+ *,
+ segment_start: int,
+ segment_end: int,
+ anchor_start: int,
+ anchor_end: int,
+ max_gap: int,
+) -> tuple[int, int]:
+ presence = [frame_index in candidate_frames for frame_index in range(segment_start, segment_end + 1)]
+ smoothed = _close_short_internal_gaps(presence, max_gap=max_gap)
+ components = [(segment_start + start, segment_start + end) for start, end in _true_runs(smoothed)]
+ anchored = [(start, end) for start, end in components if not (end < anchor_start or start > anchor_end)]
+ if not anchored:
+ return anchor_start, anchor_end
+ return (
+ min(start for start, _ in anchored),
+ max(end for _, end in anchored),
+ )
+
+
+class YoloSamBodyTracker:
+ """Use initial SAM2 presence, then face-anchored independent local tracks."""
+
+ def __init__(
+ self,
+ *,
+ person_detector: PersonDetector,
+ mask_propagator: MaskPropagator,
+ mask_box_iou_threshold: float = BODY_MASK_BOX_IOU_THRESHOLD,
+ ) -> None:
+ self.person_detector = person_detector
+ self.mask_propagator = mask_propagator
+ self.mask_box_iou_threshold = mask_box_iou_threshold
+
+ def _initial_tracks(
+ self,
+ protocol: HumanEntityProtocol,
+ decoded_frames: dict[int, np.ndarray],
+ ) -> tuple[
+ dict[int, dict[int, BoundingBox]],
+ dict[int, list[tuple[BoundingBox, float]]],
+ ]:
+ frame_indices = list(protocol.required_frame_indices)
+ frames = [decoded_frames[index] for index in frame_indices]
+ first_people = self.person_detector.detect(frames[0])
+ people_by_frame = {0: first_people}
+ first_assignments = assign_faces_to_person_boxes(
+ {target_id: box for target_id, box in enumerate(protocol.target_face_boxes)},
+ first_people,
+ )
+ masks = self.mask_propagator.track(
+ frames,
+ {target_id: [(0, box)] for target_id, box in first_assignments.items()},
+ )
+ assignments = {0: first_assignments}
+ for local_index, frame_index in enumerate(frame_indices[1:], start=1):
+ people = self.person_detector.detect(decoded_frames[frame_index])
+ people_by_frame[frame_index] = people
+ target_ids = []
+ mask_boxes = []
+ for target_id in range(protocol.n_targets):
+ mask = masks.get(target_id, {}).get(local_index)
+ box = _mask_bbox(mask) if mask is not None else None
+ if box is not None:
+ target_ids.append(target_id)
+ mask_boxes.append(box)
+ if not mask_boxes or not people:
+ assignments[frame_index] = {}
+ continue
+ scores = np.asarray(
+ [[_box_iou(mask_box, person_box) for person_box, _ in people] for mask_box in mask_boxes],
+ dtype=np.float32,
+ )
+ matches = hungarian_matches(
+ scores,
+ threshold=self.mask_box_iou_threshold,
+ )
+ assignments[frame_index] = {target_ids[row]: people[column][0] for row, column in matches.items()}
+ return assignments, people_by_frame
+
+ def _face_reacquisition_matches(
+ self,
+ protocol: HumanEntityProtocol,
+ face_tracks: FaceTrackAnalysis,
+ initial_phases: Sequence[TargetPhase],
+ *,
+ face_reacquisition_threshold: float,
+ ) -> dict[int, dict[int, FaceObservation]]:
+ references = build_frozen_reference_libraries(
+ face_tracks,
+ initial_phases,
+ )
+ matches_by_frame: dict[int, dict[int, FaceObservation]] = {0: {}}
+ for frame_index in range(1, protocol.total_frames):
+ active_target_ids = [
+ target_id
+ for target_id, phase in enumerate(initial_phases)
+ if (phase.status != "not_departed" and frame_index > phase.visible_indices[-1])
+ ]
+ reference_sets = [references[target_id] for target_id in active_target_ids]
+ matches_by_frame[frame_index] = _frame_matches(
+ reference_sets,
+ face_tracks.candidates_by_frame[frame_index],
+ threshold=face_reacquisition_threshold,
+ target_ids=active_target_ids,
+ )
+ return matches_by_frame
+
+ @staticmethod
+ def _direct_person_assignments(
+ *,
+ stable_runs: Sequence[list[tuple[int, int]]],
+ matches_by_frame: dict[int, dict[int, FaceObservation]],
+ people_by_frame: dict[int, list[tuple[BoundingBox, float]]],
+ total_frames: int,
+ ) -> list[dict[int, BoundingBox]]:
+ direct = [dict() for _ in stable_runs]
+ for frame_index in range(total_frames):
+ eligible_faces = {}
+ for target_id, runs in enumerate(stable_runs):
+ if any(start <= frame_index <= end for start, end in runs):
+ observation = matches_by_frame.get(frame_index, {}).get(target_id)
+ if observation is not None:
+ eligible_faces[target_id] = observation.bbox
+ if not eligible_faces:
+ continue
+ mapped = assign_faces_to_person_boxes(
+ eligible_faces,
+ people_by_frame.get(frame_index, []),
+ )
+ for target_id, person_box in mapped.items():
+ direct[target_id][frame_index] = person_box
+ return direct
+
+ def _local_revisit_tracks(
+ self,
+ *,
+ target_id: int,
+ visible_end: int,
+ runs: Sequence[tuple[int, int]],
+ direct_assignments: dict[int, BoundingBox],
+ decoded_frames: dict[int, np.ndarray],
+ people_by_frame: dict[int, list[tuple[BoundingBox, float]]],
+ max_revisit_gap_frames: int,
+ ) -> tuple[dict[int, BoundingBox], list[tuple[int, int]]]:
+ viable_runs = [
+ (start, end)
+ for start, end in runs
+ if any(start <= frame_index <= end for frame_index in direct_assignments)
+ ]
+ assignments = {}
+ kept_runs = []
+ bounds = _segment_bounds(
+ viable_runs,
+ post_start=visible_end + 1,
+ final_frame=len(decoded_frames) - 1,
+ )
+ for (anchor_start, anchor_end), (segment_start, segment_end) in zip(
+ viable_runs,
+ bounds,
+ ):
+ global_prompts = _sparse_prompts(
+ direct_assignments,
+ start=anchor_start,
+ end=anchor_end,
+ )
+ segment_frames = [decoded_frames[frame_index] for frame_index in range(segment_start, segment_end + 1)]
+ masks = self.mask_propagator.track(
+ segment_frames,
+ {target_id: [(frame_index - segment_start, box) for frame_index, box in global_prompts]},
+ ).get(target_id, {})
+ candidates = {
+ frame_index: box
+ for frame_index, box in direct_assignments.items()
+ if anchor_start <= frame_index <= anchor_end
+ }
+ for local_index, mask in masks.items():
+ frame_index = segment_start + int(local_index)
+ if frame_index < segment_start or frame_index > segment_end or frame_index in candidates:
+ continue
+ mask_box = _mask_bbox(np.asarray(mask, dtype=bool))
+ people = people_by_frame.get(frame_index, [])
+ if mask_box is None or not people:
+ continue
+ scores = [_box_iou(mask_box, person_box) for person_box, _ in people]
+ best_index = int(np.argmax(scores))
+ if scores[best_index] >= self.mask_box_iou_threshold:
+ candidates[frame_index] = people[best_index][0]
+ run_start, run_end = _anchored_component(
+ set(candidates),
+ segment_start=segment_start,
+ segment_end=segment_end,
+ anchor_start=anchor_start,
+ anchor_end=anchor_end,
+ max_gap=max_revisit_gap_frames,
+ )
+ assignments.update(
+ {frame_index: box for frame_index, box in candidates.items() if run_start <= frame_index <= run_end}
+ )
+ kept_runs.append((run_start, run_end))
+ return assignments, kept_runs
+
+ def analyze(
+ self,
+ protocol: HumanEntityProtocol,
+ decoded_frames: dict[int, np.ndarray],
+ *,
+ max_revisit_gap_frames: int,
+ revisit_boundary_buffer_frames: int = REVISIT_BOUNDARY_BUFFER_FRAMES,
+ face_tracks: FaceTrackAnalysis | None = None,
+ face_reacquisition_threshold: float = BODY_REACQUISITION_FACE_THRESHOLD,
+ ) -> BodyTrackAnalysis:
+ initial_assignments, people_by_frame = self._initial_tracks(
+ protocol,
+ decoded_frames,
+ )
+ if face_tracks is None:
+ inferred = tuple(
+ infer_target_phase(
+ target_id=target_id,
+ total_frames=protocol.total_frames,
+ matched_frame_indices={
+ frame_index for frame_index, matched in initial_assignments.items() if target_id in matched
+ },
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ expected_revisits=protocol.expected_revisits,
+ revisit_boundary_buffer_frames=(revisit_boundary_buffer_frames),
+ )
+ for target_id in range(protocol.n_targets)
+ )
+ return BodyTrackAnalysis(
+ target_phases=inferred,
+ assignments=initial_assignments,
+ )
+
+ initial_phases = tuple(
+ _initial_phase(
+ target_id=target_id,
+ total_frames=protocol.total_frames,
+ assignments=initial_assignments,
+ )
+ for target_id in range(protocol.n_targets)
+ )
+ matches_by_frame = self._face_reacquisition_matches(
+ protocol,
+ face_tracks,
+ initial_phases,
+ face_reacquisition_threshold=face_reacquisition_threshold,
+ )
+ stable_runs = [
+ (
+ []
+ if phase.status == "not_departed"
+ else _stable_face_runs(
+ target_id=target_id,
+ visible_end=phase.visible_indices[-1],
+ total_frames=protocol.total_frames,
+ matches_by_frame=matches_by_frame,
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ )
+ )
+ for target_id, phase in enumerate(initial_phases)
+ ]
+ direct_assignments = self._direct_person_assignments(
+ stable_runs=stable_runs,
+ matches_by_frame=matches_by_frame,
+ people_by_frame=people_by_frame,
+ total_frames=protocol.total_frames,
+ )
+
+ final_assignments = {frame_index: {} for frame_index in range(protocol.total_frames)}
+ target_phases = []
+ for target_id, initial_phase in enumerate(initial_phases):
+ visible_end = initial_phase.visible_indices[-1]
+ if initial_phase.status == "not_departed":
+ for frame_index, matched in initial_assignments.items():
+ person_box = matched.get(target_id)
+ if person_box is not None:
+ final_assignments[frame_index][target_id] = person_box
+ target_phases.append(
+ phase_from_revisit_runs(
+ target_id=target_id,
+ visible_indices=initial_phase.visible_indices,
+ total_frames=protocol.total_frames,
+ runs=[],
+ expected_revisits=protocol.expected_revisits,
+ revisit_boundary_buffer_frames=(revisit_boundary_buffer_frames),
+ )
+ )
+ continue
+
+ for frame_index in initial_phase.visible_indices:
+ person_box = initial_assignments.get(frame_index, {}).get(target_id)
+ if person_box is not None:
+ final_assignments[frame_index][target_id] = person_box
+ local_assignments, revisit_runs = self._local_revisit_tracks(
+ target_id=target_id,
+ visible_end=visible_end,
+ runs=stable_runs[target_id],
+ direct_assignments=direct_assignments[target_id],
+ decoded_frames=decoded_frames,
+ people_by_frame=people_by_frame,
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ )
+ for frame_index, person_box in local_assignments.items():
+ final_assignments[frame_index][target_id] = person_box
+ target_phases.append(
+ phase_from_revisit_runs(
+ target_id=target_id,
+ visible_indices=initial_phase.visible_indices,
+ total_frames=protocol.total_frames,
+ runs=revisit_runs,
+ expected_revisits=protocol.expected_revisits,
+ revisit_boundary_buffer_frames=(revisit_boundary_buffer_frames),
+ )
+ )
+
+ return BodyTrackAnalysis(
+ target_phases=tuple(target_phases),
+ assignments=final_assignments,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/evaluator.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/evaluator.py
new file mode 100644
index 000000000..3be65ab87
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/evaluator.py
@@ -0,0 +1,314 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Shared resource construction and cached Human Entity analyses."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from pathlib import Path
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+from ..features import MaskedDinoV2Features, get_dino_model
+from ..revisit_protocol import MAX_SHORT_MISSED_FRAMES, REVISIT_BOUNDARY_BUFFER_FRAMES
+from .appearance_features import (
+ AppearanceBackend,
+ YoloSamDinoAppearanceBackend,
+ analyze_appearance,
+)
+from .body_tracking import (
+ BODY_REACQUISITION_FACE_THRESHOLD,
+ BodyTrackerBackend,
+ YoloSamBodyTracker,
+)
+from .face_identity import (
+ TRACK_ASSOCIATION_THRESHOLD,
+ FaceIdentityBackend,
+ InsightFaceBackend,
+ analyze_identity,
+ prepare_face_tracks,
+)
+from .person_mask_tracker import (
+ TransformersSam2MaskPropagator,
+ YoloWorldPersonDetector,
+)
+from .phase_protocol import load_human_entity_protocol
+from .schemas import AppearanceAnalysis, IdentityAnalysis
+
+YOLO_PERSON_CONFIDENCE = 0.05
+MIN_MASK_AREA_RATIO = 0.0
+DINO_BATCH_SIZE = 16
+
+
+def _face_backend(context: EvaluationContext, weight_metric="human_identity") -> FaceIdentityBackend:
+ config = context.metric_config("human_identity")
+ backend_name = str(config.get("backend", "insightface")).lower()
+ if backend_name != "insightface":
+ raise EvaluationError("human_identity.backend is fixed to 'insightface' for comparable scores")
+ model_name = str(config.get("insightface_model", "buffalo_l"))
+ model_root = config.get("insightface_root")
+ if model_root is None:
+ # InsightFace accepts an absolute model name. This loads the metric's
+ # buffalo_l link directly without creating another models/ directory.
+ model_root = context.cache_dir
+ model_name = str((context.cache_dir / "metrics" / weight_metric / model_name).resolve())
+ key = (
+ "human_entity:face_backend:insightface",
+ str((Path(model_root).expanduser() / "models" / model_name).resolve()),
+ context.device,
+ )
+ return context.get_resource(
+ key,
+ lambda: InsightFaceBackend(
+ model_name=model_name,
+ model_root=model_root,
+ device=context.device,
+ ),
+ )
+
+
+def _person_detector(context: EvaluationContext, weight_metric="human_appearance") -> YoloWorldPersonDetector:
+ config = context.metric_config("human_appearance")
+ checkpoint = str(
+ Path(config.get("yolo_checkpoint") or context.cache_dir / "metrics" / weight_metric / "yolov8s-worldv2.pt")
+ .expanduser()
+ .resolve()
+ )
+ key = (
+ "human_entity:person_detector",
+ checkpoint,
+ YOLO_PERSON_CONFIDENCE,
+ context.device,
+ )
+ return context.get_resource(
+ key,
+ lambda: YoloWorldPersonDetector(
+ checkpoint=checkpoint,
+ clip_checkpoint=str(context.cache_dir / "metrics" / weight_metric / "ViT-B-32.pt"),
+ confidence=YOLO_PERSON_CONFIDENCE,
+ device=context.device,
+ ),
+ )
+
+
+def _mask_propagator(
+ context: EvaluationContext,
+ weight_metric="human_appearance",
+) -> TransformersSam2MaskPropagator:
+ config = context.metric_config("human_appearance")
+ checkpoint = str(
+ Path(config.get("sam2_checkpoint") or context.cache_dir / "metrics" / weight_metric / "sam2-hiera-large")
+ .expanduser()
+ .resolve()
+ )
+ key = (
+ "human_entity:mask_propagator",
+ checkpoint,
+ MIN_MASK_AREA_RATIO,
+ context.device,
+ )
+ return context.get_resource(
+ key,
+ lambda: TransformersSam2MaskPropagator(
+ checkpoint=checkpoint,
+ min_mask_area_ratio=MIN_MASK_AREA_RATIO,
+ device=context.device,
+ ),
+ )
+
+
+def _dino_extractor(
+ context: EvaluationContext,
+) -> MaskedDinoV2Features:
+ config = context.metric_config("human_appearance")
+ checkpoint = str(
+ config.get("dino_checkpoint") or context.cache_dir / "metrics" / "human_appearance" / "dinov2-large"
+ )
+ raw_batch_size = config.get("dino_batch_size", DINO_BATCH_SIZE)
+ if isinstance(raw_batch_size, bool) or not isinstance(raw_batch_size, int) or raw_batch_size < 1:
+ raise EvaluationError("human_appearance.dino_batch_size must be positive")
+ key = (
+ "human_entity:dino_extractor",
+ checkpoint,
+ raw_batch_size,
+ context.device,
+ )
+ return context.get_resource(
+ key,
+ lambda: MaskedDinoV2Features(
+ model=get_dino_model(context, checkpoint),
+ device=context.device,
+ batch_size=raw_batch_size,
+ ),
+ )
+
+
+def _appearance_backend(context: EvaluationContext) -> AppearanceBackend:
+ mask_propagator = _mask_propagator(context)
+ feature_extractor = _dino_extractor(context)
+ key = (
+ "human_entity:appearance_backend",
+ id(mask_propagator),
+ id(feature_extractor),
+ )
+ return context.get_resource(
+ key,
+ lambda: YoloSamDinoAppearanceBackend(
+ mask_propagator=mask_propagator,
+ feature_extractor=feature_extractor,
+ ),
+ )
+
+
+def _body_backend(context: EvaluationContext, weight_metric="human_identity") -> BodyTrackerBackend:
+ person_detector = _person_detector(context, weight_metric)
+ mask_propagator = _mask_propagator(context, weight_metric)
+ key = (
+ "human_entity:body_backend",
+ id(person_detector),
+ id(mask_propagator),
+ )
+ return context.get_resource(
+ key,
+ lambda: YoloSamBodyTracker(
+ person_detector=person_detector,
+ mask_propagator=mask_propagator,
+ ),
+ )
+
+
+def _non_negative_int_config(name: str, value: object) -> int:
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+ raise EvaluationError(f"{name} must be a non-negative integer")
+ return value
+
+
+def identity_analysis_for_item(
+ item: dict,
+ context: EvaluationContext,
+ *,
+ backend: FaceIdentityBackend | None = None,
+ body_backend: BodyTrackerBackend | None = None,
+ reader: VideoReader | None = None,
+ weight_metric: str = "human_identity",
+) -> IdentityAnalysis:
+ local_path = item["video"]
+ video_reader = reader or VideoReader()
+ info = video_reader.probe(local_path)
+ protocol = load_human_entity_protocol(
+ item,
+ total_frames=info.total_frames,
+ frame_width=info.width,
+ frame_height=info.height,
+ )
+ selected_backend = backend or _face_backend(context, weight_metric)
+ selected_body_backend = body_backend or _body_backend(context, weight_metric)
+ identity_config = context.metric_config("human_identity")
+ max_revisit_gap_frames = _non_negative_int_config(
+ "human_identity.max_revisit_gap_frames",
+ identity_config.get("max_revisit_gap_frames", MAX_SHORT_MISSED_FRAMES),
+ )
+ revisit_boundary_buffer_frames = _non_negative_int_config(
+ "human_identity.revisit_boundary_buffer_frames",
+ identity_config.get(
+ "revisit_boundary_buffer_frames",
+ REVISIT_BOUNDARY_BUFFER_FRAMES,
+ ),
+ )
+ track_threshold = TRACK_ASSOCIATION_THRESHOLD
+ body_reacquisition_threshold = BODY_REACQUISITION_FACE_THRESHOLD
+ cache_key = (
+ "human_entity:identity_analysis",
+ item["item_id"],
+ local_path,
+ id(selected_backend),
+ id(selected_body_backend),
+ track_threshold,
+ body_reacquisition_threshold,
+ max_revisit_gap_frames,
+ revisit_boundary_buffer_frames,
+ )
+
+ def create() -> IdentityAnalysis:
+ decoded = video_reader.read_indices(
+ local_path,
+ list(protocol.required_frame_indices),
+ )
+ decoded_by_index = {record.frame_idx: record.rgb for record in decoded}
+ face_tracks = prepare_face_tracks(
+ protocol=protocol,
+ decoded_frames=decoded_by_index,
+ backend=selected_backend,
+ track_association_threshold=track_threshold,
+ )
+ body = selected_body_backend.analyze(
+ protocol,
+ decoded_by_index,
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ revisit_boundary_buffer_frames=revisit_boundary_buffer_frames,
+ face_tracks=face_tracks,
+ face_reacquisition_threshold=body_reacquisition_threshold,
+ )
+ return analyze_identity(
+ protocol=protocol,
+ decoded_frames=decoded_by_index,
+ backend=selected_backend,
+ video_path=local_path,
+ face_tracks=face_tracks,
+ track_association_threshold=track_threshold,
+ target_phases=body.target_phases,
+ body_assignments=body.assignments,
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ revisit_boundary_buffer_frames=revisit_boundary_buffer_frames,
+ )
+
+ return context.get_cached(
+ cache_key,
+ create,
+ )
+
+
+def appearance_analysis_for_item(
+ item: dict,
+ context: EvaluationContext,
+ *,
+ face_backend: FaceIdentityBackend | None = None,
+ appearance_backend: AppearanceBackend | None = None,
+ reader: VideoReader | None = None,
+ body_backend: BodyTrackerBackend | None = None,
+) -> tuple[IdentityAnalysis, AppearanceAnalysis]:
+ identity = identity_analysis_for_item(
+ item,
+ context,
+ backend=face_backend,
+ body_backend=body_backend,
+ reader=reader,
+ weight_metric="human_appearance",
+ )
+ video_reader = reader or VideoReader()
+ local_path = identity.video_path
+ decoded = video_reader.read_indices(
+ local_path,
+ list(identity.protocol.required_frame_indices),
+ )
+ # Appearance currently re-decodes the full item to keep the shared cache
+ # free of hundreds of decoded RGB frames between metric evaluations.
+ identity_with_frames = replace(
+ identity,
+ decoded_frames={record.frame_idx: record.rgb for record in decoded},
+ )
+ selected_backend = appearance_backend or _appearance_backend(context)
+ cache_key = (
+ "human_entity:appearance_analysis",
+ item["item_id"],
+ identity.video_path,
+ id(selected_backend),
+ )
+ appearance = context.get_cached(
+ cache_key,
+ lambda: analyze_appearance(identity_with_frames, selected_backend),
+ )
+ return identity, appearance
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/face_identity.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/face_identity.py
new file mode 100644
index 000000000..66e54e191
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/face_identity.py
@@ -0,0 +1,400 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Face inference, seeded phase tracking, and frozen-target identity scoring."""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Protocol
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+
+from ..revisit_protocol import (
+ MAX_SHORT_MISSED_FRAMES,
+ REVISIT_BOUNDARY_BUFFER_FRAMES,
+ infer_target_phase,
+)
+from .schemas import (
+ BoundingBox,
+ FaceObservation,
+ FaceTrackAnalysis,
+ HumanEntityProtocol,
+ IdentityAnalysis,
+ IdentityTargetResult,
+ TargetPhase,
+)
+from .target_association import cosine_matrix, hungarian_matches, normalized_embedding
+
+TRACK_ASSOCIATION_THRESHOLD = 0.4
+
+
+class FaceIdentityBackend(Protocol):
+ def embed_box(self, frame_rgb: np.ndarray, bbox: BoundingBox) -> np.ndarray: ...
+
+ def detect(self, frame_rgb: np.ndarray) -> list[FaceObservation]: ...
+
+
+class InsightFaceBackend:
+ """SCRFD detection and ArcFace embeddings from InsightFace buffalo_l."""
+
+ def __init__(
+ self,
+ *,
+ device: str,
+ model_name: str = "buffalo_l",
+ model_root: str | Path,
+ detection_size: int = 640,
+ ) -> None:
+ try:
+ import onnxruntime as ort
+ from insightface.app import FaceAnalysis
+ except ImportError as error:
+ raise EvaluationError(
+ "InsightFace backend requires insightface and onnxruntime; "
+ "install the WorldMemBench metric requirements"
+ ) from error
+
+ root = Path(model_root).expanduser().resolve()
+ model_dir = root / "models" / model_name
+ if not all((model_dir / name).is_file() for name in ("det_10g.onnx", "w600k_r50.onnx")):
+ raise EvaluationError(
+ f"InsightFace model directory not found: {model_dir}; "
+ "WorldMemBench never downloads evaluator weights implicitly"
+ )
+ if device.startswith("cuda"):
+ # Changing LD_LIBRARY_PATH after Python starts does not reliably
+ # affect dlopen. ORT's loader explicitly preloads CUDA/cuDNN from
+ # the active environment's nvidia packages. If the caller already
+ # supplied a cuDNN path, avoid loading the same libraries twice.
+ preload = getattr(ort, "preload_dlls", None)
+ if preload is not None and "cudnn" not in os.environ.get("LD_LIBRARY_PATH", ""):
+ preload(msvc=False, directory="")
+ raw_device = device.partition(":")[2]
+ device_id = int(raw_device) if raw_device else 0
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
+ else:
+ device_id = -1
+ providers = ["CPUExecutionProvider"]
+ self.app = FaceAnalysis(
+ name=model_name,
+ root=str(root),
+ allowed_modules=["detection", "recognition"],
+ providers=providers,
+ # Enable TF32 for CUDA face inference.
+ provider_options=([{"device_id": device_id, "use_tf32": 1}, {}] if device_id >= 0 else [{}]),
+ )
+ self.app.prepare(
+ ctx_id=device_id,
+ det_size=(detection_size, detection_size),
+ )
+ if device_id >= 0:
+ for model in self.app.models.values():
+ if "CUDAExecutionProvider" not in model.session.get_providers():
+ raise EvaluationError(
+ "InsightFace could not activate CUDAExecutionProvider; check ONNX Runtime CUDA/cuDNN libraries"
+ )
+ self._cached_frame: np.ndarray | None = None
+ self._cached_observations: list[FaceObservation] = []
+
+ def _observations(self, frame_rgb: np.ndarray) -> list[FaceObservation]:
+ if frame_rgb is self._cached_frame:
+ return list(self._cached_observations)
+ import cv2
+
+ faces = self.app.get(cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR))
+ observations: list[FaceObservation] = []
+ for face in faces:
+ embedding = getattr(face, "normed_embedding", None)
+ raw_box = getattr(face, "bbox", None)
+ if embedding is None or raw_box is None:
+ continue
+ try:
+ bbox = BoundingBox.from_value(
+ np.asarray(raw_box).tolist(),
+ field_name="InsightFace bbox",
+ )
+ normalized = normalized_embedding(embedding)
+ except (ValueError, EvaluationError):
+ continue
+ observations.append(FaceObservation(bbox=bbox, embedding=normalized))
+ self._cached_frame = frame_rgb
+ self._cached_observations = observations
+ return list(observations)
+
+ @staticmethod
+ def _box_iou(first: BoundingBox, second: BoundingBox) -> float:
+ x1 = max(first.x1, second.x1)
+ y1 = max(first.y1, second.y1)
+ x2 = min(first.x2, second.x2)
+ y2 = min(first.y2, second.y2)
+ intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
+ union = first.area + second.area - intersection
+ return intersection / union if union > 0.0 else 0.0
+
+ def embed_box(self, frame_rgb: np.ndarray, bbox: BoundingBox) -> np.ndarray:
+ observations = self._observations(frame_rgb)
+ if not observations:
+ raise DataInvalidError("InsightFace found no face in the first frame")
+ match = max(
+ observations,
+ key=lambda observation: self._box_iou(observation.bbox, bbox),
+ )
+ if self._box_iou(match.bbox, bbox) < 0.1:
+ raise DataInvalidError("target face bbox does not overlap an InsightFace detection")
+ return match.embedding
+
+ def detect(self, frame_rgb: np.ndarray) -> list[FaceObservation]:
+ return self._observations(frame_rgb)
+
+
+def _frame_matches(
+ reference_sets: Sequence[Sequence[np.ndarray]],
+ candidates: Sequence[FaceObservation],
+ *,
+ threshold: float,
+ target_ids: Sequence[int] | None = None,
+) -> dict[int, FaceObservation]:
+ if target_ids is None:
+ target_ids = tuple(range(len(reference_sets)))
+ if len(target_ids) != len(reference_sets):
+ raise ValueError("target_ids and reference_sets must have the same length")
+ if not candidates or not reference_sets:
+ return {}
+ candidate_embeddings = [candidate.embedding for candidate in candidates]
+ score_rows: list[np.ndarray] = []
+ for references in reference_sets:
+ similarities = cosine_matrix(references, candidate_embeddings)
+ score_rows.append(similarities.max(axis=0))
+ scores = np.stack(score_rows, axis=0)
+ matches = hungarian_matches(scores, threshold=threshold)
+ return {target_ids[row_index]: candidates[candidate_index] for row_index, candidate_index in matches.items()}
+
+
+def _seeded_track_associations(
+ *,
+ anchors: Sequence[np.ndarray],
+ seeded_association: dict[int, FaceObservation],
+ candidates_by_frame: dict[int, list[FaceObservation]],
+ total_frames: int,
+ threshold: float,
+) -> dict[int, dict[int, FaceObservation]]:
+ """MBench-style rolling tracks seeded only by the frozen frame-0 faces."""
+
+ track_embeddings: list[list[np.ndarray]] = [[anchor] for anchor in anchors]
+ missed_frames = [0 for _ in anchors]
+ references_frozen = [False for _ in anchors]
+ associations: dict[int, dict[int, FaceObservation]] = {0: seeded_association}
+ for frame_index in range(1, total_frames):
+ reference_sets = [[embeddings[-1], np.mean(embeddings, axis=0)] for embeddings in track_embeddings]
+ matches = _frame_matches(
+ reference_sets,
+ candidates_by_frame[frame_index],
+ threshold=threshold,
+ )
+ associations[frame_index] = matches
+ for target_id in range(len(anchors)):
+ observation = matches.get(target_id)
+ if observation is None:
+ if not references_frozen[target_id]:
+ missed_frames[target_id] += 1
+ if missed_frames[target_id] > MAX_SHORT_MISSED_FRAMES:
+ references_frozen[target_id] = True
+ continue
+ missed_frames[target_id] = 0
+ if references_frozen[target_id]:
+ continue
+ track_embeddings[target_id].append(normalized_embedding(observation.embedding))
+ return associations
+
+
+def prepare_face_tracks(
+ *,
+ protocol: HumanEntityProtocol,
+ decoded_frames: dict[int, np.ndarray],
+ backend: FaceIdentityBackend,
+ track_association_threshold: float = TRACK_ASSOCIATION_THRESHOLD,
+) -> FaceTrackAnalysis:
+ if not 0.0 <= track_association_threshold <= 1.0:
+ raise ValueError("track_association_threshold must be in [0, 1]")
+ first_frame = decoded_frames.get(0)
+ if first_frame is None:
+ raise EvaluationError("the decoded frame set does not contain frame 0")
+ anchors = tuple(normalized_embedding(backend.embed_box(first_frame, bbox)) for bbox in protocol.target_face_boxes)
+ if len({anchor.shape for anchor in anchors}) != 1:
+ raise EvaluationError("ArcFace anchors have inconsistent dimensions")
+ seeded_association = {
+ target_id: FaceObservation(box, anchor)
+ for target_id, (box, anchor) in enumerate(zip(protocol.target_face_boxes, anchors))
+ }
+ candidates_by_frame: dict[int, list[FaceObservation]] = {}
+ for frame_index in range(1, protocol.total_frames):
+ try:
+ frame = decoded_frames[frame_index]
+ except KeyError as error:
+ raise EvaluationError(f"the decoded frame set does not contain frame {frame_index}") from error
+ candidates_by_frame[frame_index] = backend.detect(frame)
+ track_associations = _seeded_track_associations(
+ anchors=anchors,
+ seeded_association=seeded_association,
+ candidates_by_frame=candidates_by_frame,
+ total_frames=protocol.total_frames,
+ threshold=track_association_threshold,
+ )
+ return FaceTrackAnalysis(
+ anchors=anchors,
+ seeded_association=seeded_association,
+ candidates_by_frame=candidates_by_frame,
+ track_associations=track_associations,
+ )
+
+
+def build_frozen_reference_libraries(
+ face_tracks: FaceTrackAnalysis,
+ target_phases: Sequence[TargetPhase],
+) -> tuple[tuple[np.ndarray, ...], ...]:
+ references: list[list[np.ndarray]] = [[anchor] for anchor in face_tracks.anchors]
+ for phase in target_phases:
+ for frame_index in phase.visible_indices:
+ if frame_index == 0:
+ continue
+ observation = face_tracks.track_associations[frame_index].get(phase.target_id)
+ if observation is not None:
+ references[phase.target_id].append(normalized_embedding(observation.embedding))
+ return tuple(tuple(values) for values in references)
+
+
+def _face_phases_from_associations(
+ *,
+ protocol: HumanEntityProtocol,
+ associations: dict[int, dict[int, FaceObservation]],
+ max_revisit_gap_frames: int,
+ revisit_boundary_buffer_frames: int,
+) -> tuple[TargetPhase, ...]:
+ return tuple(
+ infer_target_phase(
+ target_id=target_id,
+ total_frames=protocol.total_frames,
+ matched_frame_indices={
+ frame_index for frame_index, matches in associations.items() if target_id in matches
+ },
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ expected_revisits=protocol.expected_revisits,
+ revisit_boundary_buffer_frames=revisit_boundary_buffer_frames,
+ )
+ for target_id in range(protocol.n_targets)
+ )
+
+
+def analyze_identity(
+ *,
+ protocol: HumanEntityProtocol,
+ face_tracks: FaceTrackAnalysis | None = None,
+ decoded_frames: dict[int, np.ndarray],
+ backend: FaceIdentityBackend,
+ video_path: str,
+ target_phases: tuple[TargetPhase, ...],
+ body_assignments: dict[int, dict[int, BoundingBox]],
+ track_association_threshold: float = TRACK_ASSOCIATION_THRESHOLD,
+ max_revisit_gap_frames: int = MAX_SHORT_MISSED_FRAMES,
+ revisit_boundary_buffer_frames: int = REVISIT_BOUNDARY_BUFFER_FRAMES,
+) -> IdentityAnalysis:
+ if not 0.0 <= track_association_threshold <= 1.0:
+ raise ValueError("track_association_threshold must be in [0, 1]")
+ if face_tracks is None:
+ face_tracks = prepare_face_tracks(
+ protocol=protocol,
+ decoded_frames=decoded_frames,
+ backend=backend,
+ track_association_threshold=track_association_threshold,
+ )
+ appearance_target_phases = target_phases
+ target_phases = _face_phases_from_associations(
+ protocol=protocol,
+ associations=face_tracks.track_associations,
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ revisit_boundary_buffer_frames=revisit_boundary_buffer_frames,
+ )
+ # Identity and appearance infer D independently. Only the face-derived V
+ # may extend the frozen identity library; return observations never do.
+ frozen_references = build_frozen_reference_libraries(
+ face_tracks,
+ target_phases,
+ )
+ associations = face_tracks.track_associations
+
+ results: list[IdentityTargetResult] = []
+ for phase in target_phases:
+ target_id = phase.target_id
+
+ return_indices = phase.return_indices
+ frame_scores = {}
+ returned_embeddings: list[np.ndarray] = []
+ returned_by_frame: dict[int, np.ndarray] = {}
+ for frame_index in return_indices:
+ observation = associations.get(frame_index, {}).get(target_id)
+ if observation is None:
+ frame_scores[frame_index] = 0.0
+ continue
+ embedding = normalized_embedding(observation.embedding)
+ similarities = cosine_matrix(
+ frozen_references[target_id],
+ [embedding],
+ )[:, 0]
+ frame_scores[frame_index] = float(np.clip(similarities, 0.0, 1.0).max())
+ returned_embeddings.append(embedding)
+ returned_by_frame[frame_index] = embedding
+ coverage = len(returned_embeddings) / len(return_indices) if return_indices else 0.0
+ if returned_embeddings and phase.is_scorable:
+ cross = cosine_matrix(frozen_references[target_id], returned_embeddings)
+ best_by_embedding = np.clip(cross, 0.0, 1.0).max(axis=0)
+ similarity = float(best_by_embedding.mean())
+ else:
+ similarity = 0.0
+ revisit_scores: list[float] = []
+ revisit_coverages: list[float] = []
+ n_revisit_embeddings: list[int] = []
+ for revisit_indices in phase.revisit_indices:
+ embeddings = [returned_by_frame[index] for index in revisit_indices if index in returned_by_frame]
+ n_revisit_embeddings.append(len(embeddings))
+ revisit_coverages.append(len(embeddings) / len(revisit_indices) if revisit_indices else 0.0)
+ if embeddings and phase.is_scorable:
+ cross = cosine_matrix(frozen_references[target_id], embeddings)
+ best_by_embedding = np.clip(cross, 0.0, 1.0).max(axis=0)
+ revisit_scores.append(float(best_by_embedding.mean()))
+ else:
+ revisit_scores.append(0.0)
+ results.append(
+ IdentityTargetResult(
+ target_id=target_id,
+ phase=phase,
+ frame_scores=frame_scores,
+ similarity=similarity,
+ coverage=coverage,
+ n_reference_embeddings=len(frozen_references[target_id]),
+ n_return_embeddings=len(returned_embeddings),
+ revisit_scores=tuple(revisit_scores),
+ revisit_coverages=tuple(revisit_coverages),
+ n_revisit_embeddings=tuple(n_revisit_embeddings),
+ )
+ )
+ return IdentityAnalysis(
+ protocol=protocol,
+ target_phases=target_phases,
+ appearance_target_phases=appearance_target_phases,
+ body_assignments=body_assignments,
+ target_results=tuple(results),
+ # Keep the cross-metric cache lightweight. Appearance re-decodes the
+ # full item and attaches frames to a transient analysis copy.
+ decoded_frames={},
+ video_path=video_path,
+ max_revisit_gap_frames=max_revisit_gap_frames,
+ revisit_boundary_buffer_frames=revisit_boundary_buffer_frames,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/metric.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/metric.py
new file mode 100644
index 000000000..f2daff208
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/metric.py
@@ -0,0 +1,565 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Human Identity and Human Appearance metric plugins."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.context import EvaluationContext
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.aggregation import (
+ aggregate_video_summary,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import (
+ ItemResult,
+ ResultStatus,
+ UnitResult,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+if TYPE_CHECKING:
+ from .appearance_features import AppearanceBackend
+ from .body_tracking import BodyTrackerBackend
+ from .face_identity import FaceIdentityBackend
+from ..revisit_protocol import (
+ MAX_EXPECTED_REVISITS,
+ MIN_REAPPEAR_MATCH_RUN,
+ validate_expected_revisits,
+)
+from .schemas import IdentityAnalysis, TargetPhase
+
+_SHARED_ITEM_METADATA = (
+ "video_path",
+ "n_targets",
+ "target_face_bboxes_first_frame",
+ "target_descriptions",
+ "expected_revisits",
+ "revisit_mode",
+ "first_frame_match_status",
+ "first_frame_match_error",
+ "phase_source",
+ "phase_inference",
+)
+_SHARED_TARGET_METADATA = (
+ "target_id",
+ "phase_status",
+ "V",
+ "D",
+ "R",
+ "revisits",
+)
+
+
+def _identity_metadata(
+ identity: IdentityAnalysis,
+ *,
+ phase_source: str,
+) -> dict:
+ from .body_tracking import BODY_MASK_BOX_IOU_THRESHOLD, BODY_REACQUISITION_FACE_THRESHOLD
+ from .face_identity import TRACK_ASSOCIATION_THRESHOLD
+
+ protocol = identity.protocol
+ return {
+ "video_path": identity.video_path,
+ "n_targets": protocol.n_targets,
+ "target_face_bboxes_first_frame": [box.as_list() for box in protocol.target_face_boxes],
+ "target_descriptions": list(protocol.target_descriptions),
+ "expected_revisits": protocol.expected_revisits,
+ "revisit_mode": "auto" if protocol.expected_revisits is None else "fixed",
+ "phase_source": phase_source,
+ "phase_inference": {
+ "body_mask_box_iou_threshold": BODY_MASK_BOX_IOU_THRESHOLD,
+ "body_presence_model": "first_frame_face_to_yolo_person_then_sam2_track",
+ "track_association_threshold": TRACK_ASSOCIATION_THRESHOLD,
+ "body_reacquisition_face_threshold": (BODY_REACQUISITION_FACE_THRESHOLD),
+ "max_revisit_gap_frames": identity.max_revisit_gap_frames,
+ "revisit_boundary_buffer_frames": (identity.revisit_boundary_buffer_frames),
+ "identity_revisit_model": "rolling_face_presence_frozen_v_cosine",
+ "revisit_partition": (
+ "observed_return_components"
+ if protocol.expected_revisits is None
+ else "boundary_buffer_d_insertion_components"
+ ),
+ "min_reappear_match_run": MIN_REAPPEAR_MATCH_RUN,
+ "continuous_component_split": "balanced_full_component",
+ },
+ }
+
+
+def _phase_metadata(phase: TargetPhase) -> dict:
+ return {
+ "phase_status": phase.status,
+ "V": list(phase.visible_indices),
+ "D": list(phase.departure_indices),
+ "R": list(phase.return_indices),
+ }
+
+
+def _revisit_score_fields(
+ scores: tuple[float, ...],
+ coverages: tuple[float, ...],
+ *,
+ expected_revisits: int | None,
+) -> dict[str, float]:
+ # Automatic mode has a target-dependent number of segments. Keep those
+ # scores in per_target.revisits, not misaligned video-level scalar fields.
+ if expected_revisits is None:
+ return {}
+ fields: dict[str, float] = {}
+ for index in range(MAX_EXPECTED_REVISITS):
+ revisit_id = index + 1
+ fields[f"revisit_{revisit_id}_score"] = scores[index] if index < len(scores) else 0.0
+ fields[f"revisit_{revisit_id}_coverage"] = coverages[index] if index < len(coverages) else 0.0
+ return fields
+
+
+def _official_revisit_score(
+ scores: tuple[float, ...],
+ *,
+ expected_revisits: int | None,
+) -> float:
+ if expected_revisits is None:
+ return sum(scores) / len(scores) if scores else 0.0
+ return sum(scores[:expected_revisits]) / expected_revisits
+
+
+def _revisit_metadata(
+ phase: TargetPhase,
+ scores: tuple[float, ...],
+ coverages: tuple[float, ...],
+ counts: tuple[int, ...],
+ *,
+ count_key: str,
+ expected_revisits: int | None,
+) -> list[dict]:
+ output: list[dict] = []
+ for index in range(len(scores) if expected_revisits is None else MAX_EXPECTED_REVISITS):
+ indices = phase.revisit_indices[index] if index < len(phase.revisit_indices) else ()
+ slot = phase.revisit_slots[index] if index < len(phase.revisit_slots) else ()
+ boundary = phase.revisit_nominal_boundaries[index] if index < len(phase.revisit_nominal_boundaries) else None
+ search_window = phase.revisit_search_windows[index] if index < len(phase.revisit_search_windows) else ()
+ split_source = phase.revisit_sources[index] if index < len(phase.revisit_sources) else "missing"
+ departure_evidence = (
+ phase.revisit_departure_evidence[index] if index < len(phase.revisit_departure_evidence) else False
+ )
+ output.append(
+ {
+ "revisit_id": index + 1,
+ "indices": list(indices),
+ "slot": list(slot),
+ "nominal_boundary": boundary,
+ "search_window": list(search_window),
+ "actual_window": ([indices[0], indices[-1] + 1] if indices else []),
+ "window_source": split_source,
+ "departure_evidence": departure_evidence,
+ "score": scores[index] if index < len(scores) else 0.0,
+ "coverage": coverages[index] if index < len(coverages) else 0.0,
+ count_key: counts[index] if index < len(counts) else 0,
+ }
+ )
+ return output
+
+
+def _first_frame_match(
+ item: dict,
+) -> tuple[str, str | None]:
+ preparation_error = item.get("human_preparation_error")
+ if preparation_error:
+ error_type = DataInvalidError if preparation_error["status"] == "data_invalid" else EvaluationError
+ raise error_type(preparation_error["message"])
+ raw_status = item.get("annotations", {}).get("first_frame_match_status")
+ if raw_status not in {"ok", "failed"}:
+ raise DataInvalidError(
+ "annotations.first_frame_match_status must be 'ok' or 'failed'; "
+ "provide the source case files and run eval to prepare annotations"
+ )
+ raw_error = item.get("annotations", {}).get("first_frame_match_error")
+ if raw_error is not None and not isinstance(raw_error, str):
+ raise DataInvalidError("annotations.first_frame_match_error must be a string or null")
+ error = raw_error.strip() if isinstance(raw_error, str) else None
+ if raw_status == "failed" and not error:
+ raise DataInvalidError(
+ "failed first_frame_match_status requires a non-blank annotations.first_frame_match_error"
+ )
+ return raw_status, error
+
+
+def _zero_revisits(*, count_key: str) -> list[dict]:
+ return [
+ {
+ "revisit_id": index + 1,
+ "indices": [],
+ "slot": [],
+ "nominal_boundary": None,
+ "search_window": [],
+ "actual_window": [],
+ "window_source": "first_frame_match_failed",
+ "departure_evidence": False,
+ "score": 0.0,
+ "coverage": 0.0,
+ count_key: 0,
+ }
+ for index in range(MAX_EXPECTED_REVISITS)
+ ]
+
+
+def _zero_units_for_first_frame_failure(
+ item: dict,
+ *,
+ metric_name: str,
+ unit_type: str,
+ prefix: str,
+ diagnostic_scores: tuple[str, str],
+ count_metadata: tuple[str, str],
+ error: str,
+) -> list[UnitResult]:
+ boxes = item.get("annotations", {}).get("target_face_bboxes_first_frame")
+ descriptions = item.get("annotations", {}).get("target_descriptions")
+ expected_revisits = validate_expected_revisits(item.get("annotations", {}).get("expected_revisits"))
+ if not isinstance(boxes, (list, tuple)) or not boxes:
+ raise DataInvalidError("annotations.target_face_bboxes_first_frame must be non-empty")
+ if not isinstance(descriptions, (list, tuple)) or len(descriptions) != len(boxes):
+ raise DataInvalidError("annotations.target_descriptions must align with target face boxes")
+ video_path = item["video"]
+ shared = {
+ "video_path": video_path,
+ "n_targets": len(boxes),
+ "target_face_bboxes_first_frame": list(boxes),
+ "target_descriptions": [str(value) for value in descriptions],
+ "expected_revisits": expected_revisits,
+ "revisit_mode": "auto" if expected_revisits is None else "fixed",
+ "first_frame_match_status": "failed",
+ "first_frame_match_error": error,
+ "phase_source": "first_frame_match_failure",
+ "phase_inference": {
+ "zero_score_policy": "whole_model_case_trajectory",
+ "reason": error,
+ },
+ }
+ zero_scores = {
+ "score": 0.0,
+ diagnostic_scores[0]: 0.0,
+ diagnostic_scores[1]: 0.0,
+ **_revisit_score_fields((), (), expected_revisits=expected_revisits),
+ }
+ return [
+ UnitResult(
+ unit_type=unit_type,
+ unit_id=f"target_{target_id:04d}",
+ scores=dict(zero_scores),
+ status=ResultStatus.OK,
+ metadata={
+ **shared,
+ "target_id": target_id,
+ "phase_status": "first_frame_match_failed",
+ "V": [],
+ "D": [],
+ "R": [],
+ f"{prefix}_frame_scores": {},
+ count_metadata[0]: 0,
+ count_metadata[1]: 0,
+ "revisits": _zero_revisits(count_key=count_metadata[1]) if expected_revisits is not None else [],
+ },
+ )
+ for target_id in range(len(boxes))
+ ]
+
+
+def _aggregate_units(
+ item: dict,
+ units: list[UnitResult],
+ *,
+ metric_name: str,
+ prefix: str,
+ diagnostic_scores: tuple[str, ...],
+ count_metadata: tuple[str, ...],
+) -> ItemResult:
+ if not units:
+ raise EvaluationError(f"{metric_name} produced no target units")
+ non_scored = [unit for unit in units if not unit.status.is_scored]
+ if non_scored:
+ first = non_scored[0]
+ return ItemResult(
+ **{key: item.get(key) for key in ("item_id", "model_id", "category", "sample_id", "condition_id")},
+ status=first.status,
+ error=first.error,
+ metadata=first.metadata,
+ )
+ target_ids = sorted(int(unit.metadata["target_id"]) for unit in units)
+ if target_ids != list(range(len(units))):
+ raise EvaluationError(f"{metric_name} target units are incomplete")
+
+ scores = {key: sum(unit.scores[key] for unit in units) / len(units) for key in units[0].scores}
+ metadata = {key: units[0].metadata[key] for key in _SHARED_ITEM_METADATA}
+ metadata["per_target"] = [
+ {
+ **{key: unit.metadata[key] for key in _SHARED_TARGET_METADATA},
+ f"{prefix}_score": unit.scores["score"],
+ **{key: unit.scores[key] for key in diagnostic_scores},
+ **{key: unit.metadata[key] for key in count_metadata},
+ }
+ for unit in units
+ ]
+ return ItemResult(
+ **{key: item.get(key) for key in ("item_id", "model_id", "category", "sample_id", "condition_id")},
+ scores=scores,
+ num_units=len(units),
+ status=ResultStatus.OK,
+ metadata=metadata,
+ )
+
+
+class HumanIdentityMetric(Metric):
+ aggregate_summary = staticmethod(aggregate_video_summary)
+
+ name = "human_identity"
+ description = (
+ "InsightFace first-frame-seeded Hungarian face presence with "
+ "boundary-buffer departure insertion, frozen ArcFace references, "
+ "revisit splits, per-revisit official macro, and coverage."
+ )
+ unit_type = "target_identity"
+
+ def __init__(
+ self,
+ *,
+ backend: FaceIdentityBackend | None = None,
+ body_backend: BodyTrackerBackend | None = None,
+ reader: VideoReader | None = None,
+ ) -> None:
+ self.backend = backend
+ self.body_backend = body_backend
+ self.reader = reader or VideoReader()
+
+ def evaluate(
+ self,
+ item: dict,
+ context: EvaluationContext,
+ ) -> list[UnitResult]:
+ if item.get("category") not in (None, "human"):
+ return [
+ UnitResult(
+ unit_id="not_applicable",
+ status=ResultStatus.NOT_APPLICABLE,
+ metadata={"reason": "human metrics apply to human cases"},
+ )
+ ]
+ match_status, match_error = _first_frame_match(item)
+ if match_status == "failed":
+ assert match_error is not None
+ return _zero_units_for_first_frame_failure(
+ item,
+ metric_name=self.name,
+ unit_type=self.unit_type,
+ prefix="identity",
+ diagnostic_scores=("identity_similarity", "identity_coverage"),
+ count_metadata=(
+ "n_reference_embeddings",
+ "n_return_embeddings",
+ ),
+ error=match_error,
+ )
+ from .evaluator import identity_analysis_for_item
+
+ identity = identity_analysis_for_item(
+ item,
+ context,
+ backend=self.backend,
+ body_backend=self.body_backend,
+ reader=self.reader,
+ )
+ shared = _identity_metadata(
+ identity,
+ phase_source="rolling_face_tracks",
+ )
+ shared.update(
+ {
+ "first_frame_match_status": match_status,
+ "first_frame_match_error": match_error,
+ }
+ )
+ return [
+ UnitResult(
+ unit_type=self.unit_type,
+ unit_id=f"target_{result.target_id:04d}",
+ scores={
+ "score": _official_revisit_score(
+ result.revisit_scores,
+ expected_revisits=identity.protocol.expected_revisits,
+ ),
+ "identity_similarity": result.similarity,
+ "identity_coverage": result.coverage,
+ **_revisit_score_fields(
+ result.revisit_scores,
+ result.revisit_coverages,
+ expected_revisits=identity.protocol.expected_revisits,
+ ),
+ },
+ status=ResultStatus.OK,
+ metadata={
+ **shared,
+ "target_id": result.target_id,
+ **_phase_metadata(result.phase),
+ "identity_frame_scores": {str(index): score for index, score in result.frame_scores.items()},
+ "n_reference_embeddings": result.n_reference_embeddings,
+ "n_return_embeddings": result.n_return_embeddings,
+ "revisits": _revisit_metadata(
+ result.phase,
+ result.revisit_scores,
+ result.revisit_coverages,
+ result.n_revisit_embeddings,
+ count_key="n_return_embeddings",
+ expected_revisits=identity.protocol.expected_revisits,
+ ),
+ },
+ )
+ for result in identity.target_results
+ ]
+
+ def aggregate_item(
+ self,
+ item: dict,
+ units: list[UnitResult],
+ ) -> ItemResult:
+ return _aggregate_units(
+ item,
+ units,
+ metric_name=self.name,
+ prefix="identity",
+ diagnostic_scores=("identity_similarity", "identity_coverage"),
+ count_metadata=("n_reference_embeddings", "n_return_embeddings"),
+ )
+
+
+class HumanAppearanceMetric(Metric):
+ aggregate_summary = staticmethod(aggregate_video_summary)
+
+ name = "human_appearance"
+ description = (
+ "Seeded-body presence with boundary-buffer departure insertion, "
+ "phase-local YOLO-World/SAM2 person masks, component-level revisit "
+ "splits, per-revisit official macro, and DINOv2-Large similarity."
+ )
+ unit_type = "target_appearance"
+
+ def __init__(
+ self,
+ *,
+ face_backend: FaceIdentityBackend | None = None,
+ body_backend: BodyTrackerBackend | None = None,
+ appearance_backend: AppearanceBackend | None = None,
+ reader: VideoReader | None = None,
+ ) -> None:
+ self.face_backend = face_backend
+ self.body_backend = body_backend
+ self.appearance_backend = appearance_backend
+ self.reader = reader or VideoReader()
+
+ def evaluate(
+ self,
+ item: dict,
+ context: EvaluationContext,
+ ) -> list[UnitResult]:
+ if item.get("category") not in (None, "human"):
+ return [
+ UnitResult(
+ unit_id="not_applicable",
+ status=ResultStatus.NOT_APPLICABLE,
+ metadata={"reason": "human metrics apply to human cases"},
+ )
+ ]
+ match_status, match_error = _first_frame_match(item)
+ if match_status == "failed":
+ assert match_error is not None
+ return _zero_units_for_first_frame_failure(
+ item,
+ metric_name=self.name,
+ unit_type=self.unit_type,
+ prefix="appearance",
+ diagnostic_scores=(
+ "appearance_similarity",
+ "reappearance_coverage",
+ ),
+ count_metadata=(
+ "n_reference_features",
+ "n_return_features",
+ ),
+ error=match_error,
+ )
+ from .evaluator import appearance_analysis_for_item
+
+ identity, appearance = appearance_analysis_for_item(
+ item,
+ context,
+ face_backend=self.face_backend,
+ body_backend=self.body_backend,
+ appearance_backend=self.appearance_backend,
+ reader=self.reader,
+ )
+ shared = _identity_metadata(
+ identity,
+ phase_source="seeded_body_tracks",
+ )
+ shared.update(
+ {
+ "first_frame_match_status": match_status,
+ "first_frame_match_error": match_error,
+ }
+ )
+ return [
+ UnitResult(
+ unit_type=self.unit_type,
+ unit_id=f"target_{result.target_id:04d}",
+ scores={
+ "score": _official_revisit_score(
+ result.revisit_scores,
+ expected_revisits=identity.protocol.expected_revisits,
+ ),
+ "appearance_similarity": result.score,
+ "reappearance_coverage": result.reappearance_coverage,
+ **_revisit_score_fields(
+ result.revisit_scores,
+ result.revisit_coverages,
+ expected_revisits=identity.protocol.expected_revisits,
+ ),
+ },
+ status=ResultStatus.OK,
+ metadata={
+ **shared,
+ "target_id": result.target_id,
+ **_phase_metadata(result.phase),
+ "appearance_frame_scores": {str(index): score for index, score in result.frame_scores.items()},
+ "n_reference_features": result.n_reference_features,
+ "n_return_features": result.n_return_features,
+ "revisits": _revisit_metadata(
+ result.phase,
+ result.revisit_scores,
+ result.revisit_coverages,
+ result.n_revisit_features,
+ count_key="n_return_features",
+ expected_revisits=identity.protocol.expected_revisits,
+ ),
+ },
+ )
+ for result in appearance.target_results
+ ]
+
+ def aggregate_item(
+ self,
+ item: dict,
+ units: list[UnitResult],
+ ) -> ItemResult:
+ return _aggregate_units(
+ item,
+ units,
+ metric_name=self.name,
+ prefix="appearance",
+ diagnostic_scores=("appearance_similarity", "reappearance_coverage"),
+ count_metadata=("n_reference_features", "n_return_features"),
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/person_mask_tracker.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/person_mask_tracker.py
new file mode 100644
index 000000000..2633cfc90
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/person_mask_tracker.py
@@ -0,0 +1,224 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""YOLO-World person anchors and phase-local SAM2 video propagation."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any, Protocol
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+from .schemas import BoundingBox
+
+
+class PersonDetector(Protocol):
+ def detect(self, frame_rgb: np.ndarray) -> list[tuple[BoundingBox, float]]: ...
+
+
+class MaskPropagator(Protocol):
+ def track(
+ self,
+ frames_rgb: Sequence[np.ndarray],
+ prompts: dict[int, list[tuple[int, BoundingBox]]],
+ *,
+ propagate: bool = True,
+ ) -> dict[int, dict[int, np.ndarray]]: ...
+
+
+class YoloWorldPersonDetector:
+ """Ultralytics YOLO-World detector restricted to the ``person`` class."""
+
+ def __init__(
+ self,
+ *,
+ checkpoint: str,
+ clip_checkpoint: str,
+ device: str,
+ confidence: float = 0.05,
+ ) -> None:
+ try:
+ from ultralytics import YOLOWorld
+ from ultralytics.nn.text_model import CLIP
+ except ImportError as error:
+ raise EvaluationError(
+ "Human Appearance requires ultralytics YOLO-World; install the WorldMemBench metric requirements"
+ ) from error
+ checkpoint_path = Path(checkpoint).expanduser().resolve()
+ if not checkpoint_path.is_file():
+ raise EvaluationError(f"YOLO-World checkpoint not found: {checkpoint_path}")
+ clip_path = Path(clip_checkpoint).expanduser().resolve()
+ if not clip_path.is_file():
+ raise EvaluationError(f"YOLO-World CLIP checkpoint not found: {clip_path}")
+ self.model = YOLOWorld(str(checkpoint_path))
+ # The upstream CLIP wrapper forwards `size` to clip.load, which also
+ # accepts a local file. Seed its cache so set_classes never downloads.
+ self.model.model.clip_model = CLIP(
+ str(clip_path),
+ device=next(self.model.model.parameters()).device,
+ )
+ self.model.set_classes(["person"])
+ self.device = device
+ self.confidence = confidence
+
+ def detect(self, frame_rgb: np.ndarray) -> list[tuple[BoundingBox, float]]:
+ results = self.model.predict(
+ source=frame_rgb,
+ classes=[0],
+ conf=self.confidence,
+ device=self.device,
+ verbose=False,
+ )
+ detections: list[tuple[BoundingBox, float]] = []
+ if not results:
+ return detections
+ boxes = getattr(results[0], "boxes", None)
+ if boxes is None:
+ return detections
+ coordinates = boxes.xyxy.detach().float().cpu().numpy()
+ confidences = boxes.conf.detach().float().cpu().numpy()
+ for raw_box, confidence in zip(coordinates, confidences):
+ try:
+ box = BoundingBox.from_value(raw_box.tolist(), field_name="YOLO person")
+ except ValueError:
+ continue
+ detections.append((box, float(confidence)))
+ return detections
+
+
+class TransformersSam2MaskPropagator:
+ """SAM2 video tracking in both temporal directions within one phase."""
+
+ def __init__(
+ self,
+ *,
+ checkpoint: str,
+ device: str,
+ min_mask_area_ratio: float = 0.0,
+ ) -> None:
+ try:
+ from transformers import Sam2VideoModel, Sam2VideoProcessor
+ except ImportError as error:
+ raise EvaluationError(
+ "Human Appearance requires transformers with Sam2VideoModel; "
+ "install the WorldMemBench metric requirements"
+ ) from error
+ checkpoint_path = Path(checkpoint).expanduser().resolve()
+ if not checkpoint_path.exists():
+ raise EvaluationError(f"SAM2 checkpoint not found: {checkpoint_path}")
+ self.processor = Sam2VideoProcessor.from_pretrained(
+ checkpoint_path,
+ local_files_only=True,
+ )
+ self.model = (
+ Sam2VideoModel.from_pretrained(
+ checkpoint_path,
+ local_files_only=True,
+ )
+ .to(device)
+ .eval()
+ )
+ self.device = device
+ self.min_mask_area_ratio = min_mask_area_ratio
+
+ def track(
+ self,
+ frames_rgb: Sequence[np.ndarray],
+ prompts: dict[int, list[tuple[int, BoundingBox]]],
+ *,
+ propagate: bool = True,
+ ) -> dict[int, dict[int, np.ndarray]]:
+ import torch
+ from PIL import Image
+
+ if not frames_rgb or not prompts:
+ return {}
+ height, width = frames_rgb[0].shape[:2]
+ pil_frames = [Image.fromarray(frame) for frame in frames_rgb]
+ session = self.processor.init_video_session(
+ video=pil_frames,
+ inference_device=self.device,
+ # Evicted image features are recomputed on demand. Temporal memory,
+ # conditioning masks, frame order and numeric precision are unchanged.
+ max_vision_features_cache_size=1,
+ )
+ known_ids = sorted(prompts)
+ tracks: dict[int, dict[int, np.ndarray]] = {target_id: {} for target_id in known_ids}
+
+ def store_output(
+ output: Any,
+ *,
+ prompted_target_id: int | None = None,
+ ) -> None:
+ masks = getattr(output, "pred_masks", None)
+ if masks is None:
+ return
+ processed = self.processor.post_process_masks(
+ [masks],
+ original_sizes=[[height, width]],
+ binarize=True,
+ )[0]
+ binary = processed.detach().cpu().numpy()
+ if binary.ndim == 4 and binary.shape[1] == 1:
+ binary = binary[:, 0]
+ output_ids = list(session.obj_ids)
+ for index, target_id in enumerate(output_ids):
+ target_id = int(target_id)
+ if (
+ index >= binary.shape[0]
+ or target_id not in tracks
+ or (prompted_target_id is not None and target_id != prompted_target_id)
+ ):
+ continue
+ mask = np.asarray(binary[index], dtype=bool)
+ if mask.shape != (height, width):
+ continue
+ if float(mask.mean()) <= self.min_mask_area_ratio:
+ continue
+ frame_index = int(output.frame_idx)
+ if prompted_target_id is not None:
+ # A mask inferred directly from this frame's person box is
+ # more reliable than any propagated mask at the same frame.
+ tracks[target_id][frame_index] = mask
+ else:
+ tracks[target_id].setdefault(frame_index, mask)
+
+ first_prompt = len(frames_rgb)
+ last_prompt = 0
+ with torch.inference_mode():
+ for target_id in known_ids:
+ for frame_index, box in prompts[target_id]:
+ first_prompt = min(first_prompt, frame_index)
+ last_prompt = max(last_prompt, frame_index)
+ self.processor.add_inputs_to_inference_session(
+ session,
+ frame_idx=frame_index,
+ obj_ids=target_id,
+ input_boxes=[[[float(value) for value in box.as_list()]]],
+ clear_old_inputs=True,
+ )
+ # Hugging Face SAM2 marks new inputs at object scope rather
+ # than frame scope. Consume each box immediately so every
+ # prompted frame becomes a conditioning frame before the
+ # next prompt overwrites that object-level marker.
+ output = self.model(session, frame_idx=frame_index)
+ store_output(output, prompted_target_id=target_id)
+
+ if not propagate:
+ return tracks
+
+ directions = ((first_prompt, False), (last_prompt, True))
+ for start_frame, reverse in directions:
+ with torch.inference_mode():
+ iterator = self.model.propagate_in_video_iterator(
+ session,
+ start_frame_idx=start_frame,
+ reverse=reverse,
+ )
+ for output in iterator:
+ store_output(output)
+ return tracks
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/phase_protocol.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/phase_protocol.py
new file mode 100644
index 000000000..e49650ad9
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/phase_protocol.py
@@ -0,0 +1,61 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Human input contract and shared revisit allocation."""
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.revisit_protocol import (
+ validate_expected_revisits,
+)
+
+from .schemas import BoundingBox, HumanEntityProtocol
+
+
+def load_human_entity_protocol(
+ item: dict,
+ *,
+ total_frames: int,
+ frame_width: int,
+ frame_height: int,
+) -> HumanEntityProtocol:
+ annotations = item.get("annotations", {})
+ try:
+ raw_boxes = annotations["target_face_bboxes_first_frame"]
+ raw_descriptions = annotations["target_descriptions"]
+ except KeyError as error:
+ raise DataInvalidError(f"missing annotations.{error.args[0]}") from error
+
+ expected_revisits = validate_expected_revisits(annotations.get("expected_revisits"))
+ minimum_frames = (expected_revisits or 1) + 1
+ if total_frames < minimum_frames:
+ raise DataInvalidError(
+ f"generated video is too short for the requested revisit split; requires at least {minimum_frames} frames"
+ )
+ if not isinstance(raw_boxes, (list, tuple)) or not raw_boxes:
+ raise DataInvalidError("annotations.target_face_bboxes_first_frame must contain at least one bbox")
+ try:
+ boxes = tuple(
+ BoundingBox.from_value(
+ value,
+ field_name="annotations.target_face_bboxes_first_frame",
+ )
+ for value in raw_boxes
+ )
+ except ValueError as error:
+ raise DataInvalidError(str(error)) from error
+ for box in boxes:
+ clipped = box.clipped(width=frame_width, height=frame_height)
+ if clipped != box:
+ raise DataInvalidError("target_face_bboxes_first_frame must lie inside the first frame")
+
+ if not isinstance(raw_descriptions, (list, tuple)) or len(raw_descriptions) != len(boxes):
+ raise DataInvalidError("annotations.target_descriptions must contain one description per first-frame target")
+ descriptions = tuple(str(value).strip() for value in raw_descriptions)
+ if any(not value for value in descriptions):
+ raise DataInvalidError("annotations.target_descriptions cannot contain blanks")
+ return HumanEntityProtocol(
+ total_frames=total_frames,
+ target_face_boxes=boxes,
+ target_descriptions=descriptions,
+ expected_revisits=expected_revisits,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/preparation.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/preparation.py
new file mode 100644
index 000000000..31e170697
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/preparation.py
@@ -0,0 +1,423 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Match annotated source targets to generated video frames."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Sequence
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+from ..revisit_protocol import validate_expected_revisits
+
+
+class ManifestBuildError(RuntimeError):
+ """A preparation failure that must block formal evaluation."""
+
+
+def _box(value: Sequence[float], *, field: str) -> list[float]:
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
+ raise ManifestBuildError(f"{field} must be [x1,y1,x2,y2]")
+ try:
+ box = [float(coordinate) for coordinate in value]
+ except (TypeError, ValueError) as error:
+ raise ManifestBuildError(f"{field} contains a non-numeric value") from error
+ if not np.isfinite(box).all():
+ raise ManifestBuildError(f"{field} contains a non-finite value")
+ if box[2] <= box[0] or box[3] <= box[1]:
+ raise ManifestBuildError(f"{field} is empty or inverted")
+ return box
+
+
+def iou(first: Sequence[float], second: Sequence[float]) -> float:
+ ax1, ay1, ax2, ay2 = first
+ bx1, by1, bx2, by2 = second
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
+ intersection = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
+ union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - intersection
+ return intersection / union if union > 0.0 else 0.0
+
+
+def normalized_embedding(value: Any) -> np.ndarray:
+ embedding = np.asarray(value, dtype=np.float32).reshape(-1)
+ if embedding.size == 0 or not np.isfinite(embedding).all():
+ raise ManifestBuildError("InsightFace returned an invalid embedding")
+ norm = float(np.linalg.norm(embedding))
+ if not np.isfinite(norm) or norm <= 1e-12:
+ raise ManifestBuildError("InsightFace returned a zero-norm embedding")
+ return embedding / norm
+
+
+def clip_box(box: Sequence[float], *, width: int, height: int) -> list[float]:
+ clipped = [
+ max(0.0, min(float(width), float(box[0]))),
+ max(0.0, min(float(height), float(box[1]))),
+ max(0.0, min(float(width), float(box[2]))),
+ max(0.0, min(float(height), float(box[3]))),
+ ]
+ return _box(clipped, field="generated InsightFace bbox")
+
+
+def load_case(
+ samples_dir: str,
+ case_id: str,
+ *,
+ description_overrides: dict[str, list[str]],
+) -> dict[str, Any]:
+ case_dir = Path(samples_dir) / case_id
+ prompt_path = case_dir / "prompt.json"
+ if not prompt_path.is_file():
+ raise ManifestBuildError(f"canonical prompt not found: {prompt_path}")
+ with open(prompt_path, encoding="utf-8") as handle:
+ data = json.load(handle)
+ annotations = data.get("annotations") or {}
+ raw_boxes = annotations.get("target_face_bboxes_first_frame") or []
+ if not raw_boxes:
+ raise ManifestBuildError(f"{case_id}: canonical target bbox list is empty")
+ boxes = [
+ _box(value, field=f"{case_id}.target_face_bboxes_first_frame[{index}]") for index, value in enumerate(raw_boxes)
+ ]
+ descriptions = description_overrides.get(
+ case_id,
+ annotations.get("target_descriptions") or [],
+ )
+ descriptions = [str(value).strip() for value in descriptions]
+ if len(descriptions) != len(boxes) or any(not value for value in descriptions):
+ raise ManifestBuildError(f"{case_id}: target_descriptions must contain {len(boxes)} non-blank values")
+ size = data.get("frame_size") or {}
+ try:
+ source_width, source_height = int(size["w"]), int(size["h"])
+ except (KeyError, TypeError, ValueError) as error:
+ raise ManifestBuildError(f"{case_id}: invalid frame_size") from error
+ for target_id, box in enumerate(boxes):
+ if box[0] < 0.0 or box[1] < 0.0 or box[2] > source_width or box[3] > source_height:
+ raise ManifestBuildError(f"{case_id}: canonical target_{target_id:03d} bbox lies outside frame_size")
+ source_image = case_dir / str(data.get("first_frame") or "first_frame.jpg")
+ if not source_image.is_file():
+ raise ManifestBuildError(f"{case_id}: source first frame not found: {source_image}")
+ return {
+ "case_id": case_id,
+ "category": data.get("category"),
+ "boxes": boxes,
+ "descriptions": descriptions,
+ "source_width": source_width,
+ "source_height": source_height,
+ "source_image": source_image,
+ }
+
+
+def detect_faces(app, frame_bgr: np.ndarray, *, min_det_score: float) -> list[dict]:
+ detections: list[dict] = []
+ for face in app.get(frame_bgr):
+ raw_box = getattr(face, "bbox", None)
+ raw_embedding = getattr(face, "normed_embedding", None)
+ confidence = float(getattr(face, "det_score", 0.0))
+ if raw_box is None or raw_embedding is None or confidence < min_det_score:
+ continue
+ detections.append(
+ {
+ "bbox": _box(np.asarray(raw_box).tolist(), field="InsightFace bbox"),
+ "embedding": normalized_embedding(raw_embedding),
+ "det_score": confidence,
+ }
+ )
+ return detections
+
+
+def _hungarian_pairs(scores: np.ndarray) -> list[tuple[int, int]]:
+ if scores.ndim != 2:
+ raise ValueError("assignment scores must be two-dimensional")
+ if scores.shape[0] == 0 or scores.shape[1] == 0:
+ return []
+ from scipy.optimize import linear_sum_assignment
+
+ rows, columns = linear_sum_assignment(scores, maximize=True)
+ return [(int(row), int(column)) for row, column in zip(rows, columns)]
+
+
+def source_anchors(
+ canonical_boxes: Sequence[Sequence[float]],
+ detections: Sequence[dict],
+ *,
+ iou_threshold: float,
+) -> tuple[list[np.ndarray], list[dict]]:
+ if len(detections) < len(canonical_boxes):
+ raise ManifestBuildError(
+ f"source first frame has {len(detections)} detected faces but {len(canonical_boxes)} canonical targets"
+ )
+ scores = np.asarray(
+ [[iou(box, detection["bbox"]) for detection in detections] for box in canonical_boxes],
+ dtype=np.float64,
+ )
+ pairs = _hungarian_pairs(scores)
+ by_target = {target_id: detection_id for target_id, detection_id in pairs}
+ if len(by_target) != len(canonical_boxes):
+ raise ManifestBuildError("source first-frame target assignment is incomplete")
+ anchors: list[np.ndarray] = []
+ audit: list[dict] = []
+ for target_id, canonical_box in enumerate(canonical_boxes):
+ detection_id = by_target[target_id]
+ overlap = float(scores[target_id, detection_id])
+ if overlap < iou_threshold:
+ raise ManifestBuildError(
+ f"canonical target_{target_id:03d} has source detection IoU {overlap:.4f} below {iou_threshold:.4f}"
+ )
+ detection = detections[detection_id]
+ anchors.append(detection["embedding"])
+ audit.append(
+ {
+ "target_id": target_id,
+ "canonical_bbox": list(canonical_box),
+ "source_detection_bbox": detection["bbox"],
+ "source_detection_score": detection["det_score"],
+ "source_iou": overlap,
+ }
+ )
+ return anchors, audit
+
+
+def generated_target_boxes(
+ anchors: Sequence[np.ndarray],
+ detections: Sequence[dict],
+ *,
+ similarity_threshold: float,
+ width: int,
+ height: int,
+) -> tuple[list[list[float]], list[dict], list[list[float]]]:
+ if len(detections) < len(anchors):
+ raise ManifestBuildError(
+ f"generated first frame has {len(detections)} detected faces but {len(anchors)} canonical targets"
+ )
+ scores = np.asarray(
+ [[float(np.dot(anchor, detection["embedding"])) for detection in detections] for anchor in anchors],
+ dtype=np.float64,
+ )
+ pairs = _hungarian_pairs(scores)
+ by_target = {target_id: detection_id for target_id, detection_id in pairs}
+ if len(by_target) != len(anchors):
+ raise ManifestBuildError("generated first-frame identity assignment is incomplete")
+ boxes: list[list[float]] = []
+ audit: list[dict] = []
+ used: set[int] = set()
+ for target_id in range(len(anchors)):
+ detection_id = by_target[target_id]
+ similarity = float(scores[target_id, detection_id])
+ if similarity < similarity_threshold:
+ raise ManifestBuildError(
+ f"target_{target_id:03d} ArcFace similarity {similarity:.4f} below {similarity_threshold:.4f}"
+ )
+ detection = detections[detection_id]
+ box = clip_box(detection["bbox"], width=width, height=height)
+ boxes.append(box)
+ audit.append(
+ {
+ "target_id": target_id,
+ "generated_bbox": box,
+ "generated_detection_score": detection["det_score"],
+ "arcface_similarity": similarity,
+ }
+ )
+ used.add(detection_id)
+ extras = [
+ clip_box(detection["bbox"], width=width, height=height)
+ for detection_id, detection in enumerate(detections)
+ if detection_id not in used
+ ]
+ return boxes, audit, extras
+
+
+def scaled_canonical_boxes(
+ case: dict[str, Any],
+ *,
+ width: int,
+ height: int,
+) -> list[list[float]]:
+ scale_x = float(width) / float(case["source_width"])
+ scale_y = float(height) / float(case["source_height"])
+ return [
+ clip_box(
+ [
+ box[0] * scale_x,
+ box[1] * scale_y,
+ box[2] * scale_x,
+ box[3] * scale_y,
+ ],
+ width=width,
+ height=height,
+ )
+ for box in case["boxes"]
+ ]
+
+
+def probe_first_frame(video_path: str) -> tuple[int, int, np.ndarray]:
+ import cv2
+
+ capture = cv2.VideoCapture(video_path)
+ if not capture.isOpened():
+ capture.release()
+ raise ManifestBuildError(f"video is unreadable: {video_path}")
+ width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
+ height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ ok, frame = capture.read()
+ capture.release()
+ if not ok or frame is None or width <= 0 or height <= 0:
+ raise ManifestBuildError(f"failed to decode frame 0: {video_path}")
+ return width, height, frame
+
+
+def load_source_frame(case: dict[str, Any]) -> np.ndarray:
+ import cv2
+
+ frame = cv2.imread(str(case["source_image"]))
+ if frame is None:
+ raise ManifestBuildError(f"failed to read source image: {case['source_image']}")
+ height, width = frame.shape[:2]
+ if (width, height) != (case["source_width"], case["source_height"]):
+ raise ManifestBuildError(
+ f"{case['case_id']}: prompt frame_size "
+ f"{case['source_width']}x{case['source_height']} does not match source image "
+ f"{width}x{height}"
+ )
+ return frame
+
+
+def prepare_samples(
+ samples: list[dict],
+ dataset: Path,
+ *,
+ expected_revisits: int | None,
+ app,
+ audit_rows: list[dict],
+) -> list[dict]:
+ """Match each generated first frame to frozen source targets, in source order."""
+ validate_expected_revisits(expected_revisits)
+ # Compare identical case sets across models within each trajectory.
+ matrix: dict[str, dict[str, set[str]]] = {}
+ for sample in samples:
+ matrix.setdefault(sample["condition_id"], {}).setdefault(sample["model_id"], set()).add(sample["sample_id"])
+ for trajectory, model_cases in matrix.items():
+ reference = next(iter(model_cases.values()))
+ if any(cases != reference for cases in model_cases.values()):
+ raise ManifestBuildError(f"model case sets differ for trajectory {trajectory}")
+ cache = {}
+ prepared = []
+ for sample in samples:
+ case_id = sample["sample_id"]
+ audit = {"item_id": sample["item_id"], "status": "error"}
+ try:
+ if case_id not in cache:
+ case = load_case(str(dataset), case_id, description_overrides={})
+ detections = detect_faces(app, load_source_frame(case), min_det_score=0.0)
+ anchors, source_audit = source_anchors(case["boxes"], detections, iou_threshold=0.1)
+ cache[case_id] = case, anchors, source_audit
+ case, anchors, source_audit = cache[case_id]
+ width, height, frame = probe_first_frame(sample["video"])
+ detections = detect_faces(app, frame, min_det_score=0.0)
+ match_status, match_error = "ok", None
+ try:
+ boxes, generated_audit, extras = generated_target_boxes(
+ anchors,
+ detections,
+ similarity_threshold=0.35,
+ width=width,
+ height=height,
+ )
+ target_audit = [{**source, **generated} for source, generated in zip(source_audit, generated_audit)]
+ except ManifestBuildError as error:
+ # The reference gives the whole model/case/trajectory zero,
+ # retaining the canonical target count and explicit reason.
+ match_status, match_error = "failed", str(error)
+ boxes = scaled_canonical_boxes(case, width=width, height=height)
+ extras = [clip_box(detection["bbox"], width=width, height=height) for detection in detections]
+ target_audit = [
+ {
+ **source,
+ "generated_bbox": boxes[index],
+ "assignment_method": "first_frame_match_failed",
+ "first_frame_match_error": match_error,
+ }
+ for index, source in enumerate(source_audit)
+ ]
+ prepared.append(
+ {
+ **sample,
+ "annotations": {
+ "target_face_bboxes_first_frame": boxes,
+ "target_descriptions": list(case["descriptions"]),
+ "expected_revisits": expected_revisits,
+ "first_frame_match_status": match_status,
+ "first_frame_match_error": match_error,
+ },
+ }
+ )
+ audit.update(
+ status="ok",
+ first_frame_match_status=match_status,
+ first_frame_match_error=match_error,
+ target_matches=target_audit,
+ extra_first_frame_face_bboxes=extras,
+ )
+ print(f"{sample['item_id']}: first-frame match {match_status}", flush=True)
+ except Exception as error:
+ audit["error"] = f"{type(error).__name__}: {error}"
+ raise
+ finally:
+ audit_rows.append(audit)
+ return prepared
+
+
+def prepare_for_evaluation(
+ samples: list[dict], context, *, expected_revisits: int | None, weight_metric: str = "human_identity"
+) -> None:
+ """Annotate standard-layout Human inputs before publishing the run manifest."""
+ from .evaluator import _face_backend
+
+ for sample in samples:
+ if sample.get("category") not in (None, "human"):
+ continue
+ if "first_frame_match_status" in (sample.get("annotations") or {}):
+ if expected_revisits is not None:
+ sample["annotations"]["expected_revisits"] = validate_expected_revisits(expected_revisits)
+ continue
+ if sample.get("category") != "human":
+ continue
+ sample.pop("human_preparation_error", None)
+ try:
+ selected_revisits = validate_expected_revisits(
+ expected_revisits
+ if expected_revisits is not None
+ else (sample.get("annotations") or {}).get("expected_revisits")
+ )
+ video = Path(sample["video"])
+ if video.name != "video.mp4" or video.parent.name != sample.get("sample_id"):
+ raise DataInvalidError("automatic Human preparation requires video.mp4 in the case folder")
+ # Source image/annotations and generated video share the case folder.
+ source_root = video.parents[1]
+ try:
+ case = load_case(str(source_root), sample["sample_id"], description_overrides={})
+ load_source_frame(case)
+ except Exception as error:
+ raise DataInvalidError(str(error)) from error
+ prepared = prepare_samples(
+ [sample],
+ source_root,
+ expected_revisits=selected_revisits,
+ app=_face_backend(context, weight_metric).app,
+ audit_rows=[],
+ )
+ sample.update(prepared[0])
+ except Exception as error:
+ # Persist preparation failures as input provenance and let each
+ # Human metric report them through the usual per-case error path.
+ sample["human_preparation_error"] = {
+ "status": "data_invalid"
+ if isinstance(error, (DataInvalidError, ManifestBuildError))
+ else "eval_failed",
+ "message": f"{type(error).__name__}: {error}",
+ }
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/schemas.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/schemas.py
new file mode 100644
index 000000000..e6b91bf8f
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/schemas.py
@@ -0,0 +1,155 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Typed records shared by the Human Entity Memory metrics."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.revisit_protocol import TargetPhase
+
+
+@dataclass(frozen=True)
+class BoundingBox:
+ x1: float
+ y1: float
+ x2: float
+ y2: float
+
+ @classmethod
+ def from_value(cls, value: Any, *, field_name: str) -> BoundingBox:
+ import numpy as np
+
+ if isinstance(value, dict):
+ try:
+ coordinates = [value[key] for key in ("x1", "y1", "x2", "y2")]
+ except KeyError as error:
+ raise ValueError(f"{field_name} bbox mappings require x1/y1/x2/y2") from error
+ elif isinstance(value, (list, tuple)) and len(value) == 4:
+ coordinates = list(value)
+ else:
+ raise ValueError(f"{field_name} must contain [x1, y1, x2, y2] boxes")
+ try:
+ box = cls(*(float(coordinate) for coordinate in coordinates))
+ except (TypeError, ValueError) as error:
+ raise ValueError(f"{field_name} contains a non-numeric bbox") from error
+ if not np.isfinite(box.as_array()).all():
+ raise ValueError(f"{field_name} contains a non-finite bbox")
+ if box.x2 <= box.x1 or box.y2 <= box.y1:
+ raise ValueError(f"{field_name} contains an empty or inverted bbox")
+ return box
+
+ def as_array(self) -> np.ndarray:
+ import numpy as np
+
+ return np.asarray([self.x1, self.y1, self.x2, self.y2], dtype=np.float32)
+
+ def as_list(self) -> list[float]:
+ return [self.x1, self.y1, self.x2, self.y2]
+
+ @property
+ def center(self) -> tuple[float, float]:
+ return ((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
+
+ @property
+ def area(self) -> float:
+ return (self.x2 - self.x1) * (self.y2 - self.y1)
+
+ def contains(self, point: tuple[float, float]) -> bool:
+ x, y = point
+ return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2
+
+ def clipped(self, *, width: int, height: int) -> BoundingBox:
+ return BoundingBox(
+ x1=max(0.0, min(float(width), self.x1)),
+ y1=max(0.0, min(float(height), self.y1)),
+ x2=max(0.0, min(float(width), self.x2)),
+ y2=max(0.0, min(float(height), self.y2)),
+ )
+
+
+@dataclass(frozen=True)
+class HumanEntityProtocol:
+ total_frames: int
+ target_face_boxes: tuple[BoundingBox, ...]
+ target_descriptions: tuple[str, ...]
+ expected_revisits: int | None
+
+ @property
+ def n_targets(self) -> int:
+ return len(self.target_face_boxes)
+
+ @property
+ def required_frame_indices(self) -> tuple[int, ...]:
+ return tuple(range(self.total_frames))
+
+
+@dataclass
+class BodyTrackAnalysis:
+ target_phases: tuple[TargetPhase, ...]
+ assignments: dict[int, dict[int, BoundingBox]]
+
+
+@dataclass(frozen=True)
+class FaceObservation:
+ bbox: BoundingBox
+ embedding: np.ndarray
+
+
+@dataclass
+class FaceTrackAnalysis:
+ anchors: tuple[np.ndarray, ...]
+ seeded_association: dict[int, FaceObservation]
+ candidates_by_frame: dict[int, list[FaceObservation]]
+ track_associations: dict[int, dict[int, FaceObservation]]
+
+
+@dataclass(frozen=True)
+class IdentityTargetResult:
+ target_id: int
+ phase: TargetPhase
+ frame_scores: dict[int, float]
+ similarity: float
+ coverage: float
+ n_reference_embeddings: int
+ n_return_embeddings: int
+ revisit_scores: tuple[float, ...]
+ revisit_coverages: tuple[float, ...]
+ n_revisit_embeddings: tuple[int, ...]
+
+
+@dataclass
+class IdentityAnalysis:
+ protocol: HumanEntityProtocol
+ target_phases: tuple[TargetPhase, ...]
+ appearance_target_phases: tuple[TargetPhase, ...]
+ body_assignments: dict[int, dict[int, BoundingBox]]
+ target_results: tuple[IdentityTargetResult, ...]
+ max_revisit_gap_frames: int
+ revisit_boundary_buffer_frames: int
+ decoded_frames: dict[int, np.ndarray] = field(repr=False)
+ video_path: str = ""
+
+
+@dataclass(frozen=True)
+class AppearanceTargetResult:
+ target_id: int
+ phase: TargetPhase
+ frame_scores: dict[int, float]
+ score: float
+ reappearance_coverage: float
+ n_reference_features: int
+ n_return_features: int
+ revisit_scores: tuple[float, ...]
+ revisit_coverages: tuple[float, ...]
+ n_revisit_features: tuple[int, ...]
+
+
+@dataclass(frozen=True)
+class AppearanceAnalysis:
+ target_results: tuple[AppearanceTargetResult, ...]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/target_association.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/target_association.py
new file mode 100644
index 000000000..3475ca8f8
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/human/target_association.py
@@ -0,0 +1,85 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""One-to-one target association and cosine helpers."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+from .schemas import BoundingBox
+
+
+def normalized_embedding(value: np.ndarray | Sequence[float]) -> np.ndarray:
+ embedding = np.asarray(value, dtype=np.float32).reshape(-1)
+ if embedding.size == 0 or not np.isfinite(embedding).all():
+ raise EvaluationError("embedding must be finite and non-empty")
+ norm = float(np.linalg.norm(embedding))
+ if not np.isfinite(norm) or norm <= 1e-12:
+ raise EvaluationError("embedding has zero or non-finite norm")
+ return embedding / norm
+
+
+def cosine_matrix(
+ references: Sequence[np.ndarray],
+ candidates: Sequence[np.ndarray],
+) -> np.ndarray:
+ if not references or not candidates:
+ return np.empty((len(references), len(candidates)), dtype=np.float32)
+ reference_matrix = np.stack([normalized_embedding(value) for value in references], axis=0)
+ candidate_matrix = np.stack([normalized_embedding(value) for value in candidates], axis=0)
+ return reference_matrix @ candidate_matrix.T
+
+
+def hungarian_matches(
+ scores: np.ndarray,
+ *,
+ threshold: float,
+) -> dict[int, int]:
+ matrix = np.asarray(scores, dtype=np.float64)
+ if matrix.ndim != 2:
+ raise ValueError("scores must be a 2D matrix")
+ if matrix.size == 0:
+ return {}
+ if not np.isfinite(matrix).all():
+ raise EvaluationError("association matrix contains a non-finite value")
+ try:
+ from scipy.optimize import linear_sum_assignment
+ except ImportError as error:
+ raise EvaluationError(
+ "Human Entity association requires scipy; install the WorldMemBench metric requirements"
+ ) from error
+ rows, columns = linear_sum_assignment(matrix, maximize=True)
+ return {int(row): int(column) for row, column in zip(rows, columns) if matrix[row, column] >= threshold}
+
+
+def assign_faces_to_person_boxes(
+ face_boxes: dict[int, BoundingBox],
+ person_boxes: Sequence[tuple[BoundingBox, float]],
+) -> dict[int, BoundingBox]:
+ """Assign distinct containing person boxes while maximizing confidence."""
+
+ if not face_boxes or not person_boxes:
+ return {}
+ target_ids = sorted(face_boxes)
+ scores = np.full((len(target_ids), len(person_boxes)), -1e9, dtype=np.float64)
+ valid = np.zeros_like(scores, dtype=bool)
+ for row, target_id in enumerate(target_ids):
+ for column, (person_box, confidence) in enumerate(person_boxes):
+ if person_box.contains(face_boxes[target_id].center):
+ scores[row, column] = float(confidence)
+ valid[row, column] = True
+ try:
+ from scipy.optimize import linear_sum_assignment
+ except ImportError as error:
+ raise EvaluationError(
+ "Human Appearance person assignment requires scipy; install the WorldMemBench metric requirements"
+ ) from error
+ rows, columns = linear_sum_assignment(scores, maximize=True)
+ return {
+ target_ids[int(row)]: person_boxes[int(column)][0] for row, column in zip(rows, columns) if valid[row, column]
+ }
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/__init__.py
new file mode 100644
index 000000000..cb0cfe906
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Object Memory metrics."""
+
+from .metric import ObjectAppearanceMetric, ObjectGeometryMetric
+
+__all__ = ["ObjectAppearanceMetric", "ObjectGeometryMetric"]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/appearance_features.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/appearance_features.py
new file mode 100644
index 000000000..5f2d5056e
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/appearance_features.py
@@ -0,0 +1,110 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Masked DINOv2 features and Human-aligned Object Appearance scoring."""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.human.target_association import (
+ cosine_matrix,
+ normalized_embedding,
+)
+
+from .schemas import ObjectAppearanceResult, ObjectTrackAnalysis
+
+
+class ObjectAppearanceFeatureExtractor(Protocol):
+ def embed(self, frame_rgb: np.ndarray, mask: np.ndarray) -> np.ndarray | None: ...
+
+
+def analyze_object_appearance(
+ track: ObjectTrackAnalysis,
+ feature_extractor: ObjectAppearanceFeatureExtractor,
+) -> ObjectAppearanceResult:
+ phase = track.phase
+ frame_scores: dict[int, float] = {frame_index: 0.0 for frame_index in phase.return_indices}
+ if not phase.is_scorable:
+ empty_slots = tuple(0.0 for _ in phase.revisit_indices)
+ empty_counts = tuple(0 for _ in phase.revisit_indices)
+ return ObjectAppearanceResult(
+ phase=phase,
+ frame_scores=frame_scores,
+ similarity=0.0,
+ appearance_coverage=0.0,
+ n_reference_features=0,
+ n_return_features=0,
+ revisit_scores=empty_slots,
+ revisit_coverages=empty_slots,
+ n_revisit_features=empty_counts,
+ )
+ references: list[np.ndarray] = []
+ for frame_index in phase.visible_indices:
+ observation = track.observations[frame_index]
+ if not observation.present or observation.mask is None or not observation.mask_valid:
+ continue
+ embedding = feature_extractor.embed(
+ track.decoded_frames[frame_index],
+ observation.mask,
+ )
+ if embedding is not None:
+ references.append(normalized_embedding(embedding))
+
+ returned_by_frame: dict[int, np.ndarray] = {}
+ for frame_index in phase.return_indices:
+ observation = track.observations[frame_index]
+ if not observation.present or observation.mask is None or not observation.mask_valid:
+ continue
+ candidate = feature_extractor.embed(
+ track.decoded_frames[frame_index],
+ observation.mask,
+ )
+ if candidate is None:
+ continue
+ normalized = normalized_embedding(candidate)
+ returned_by_frame[frame_index] = normalized
+ if not references:
+ continue
+ similarities = cosine_matrix(
+ references,
+ [normalized],
+ )[:, 0]
+ frame_scores[frame_index] = float(np.clip(similarities.max(), 0.0, 1.0))
+
+ return_frame_count = len(phase.return_indices)
+ n_return_features = len(returned_by_frame)
+ similarity = (
+ sum(frame_scores[index] for index in returned_by_frame) / n_return_features
+ if n_return_features and references and phase.is_scorable
+ else 0.0
+ )
+ coverage = n_return_features / return_frame_count if return_frame_count else 0.0
+ revisit_scores: list[float] = []
+ revisit_coverages: list[float] = []
+ n_revisit_features: list[int] = []
+ for revisit_indices in phase.revisit_indices:
+ feature_indices = [index for index in revisit_indices if index in returned_by_frame]
+ n_features = len(feature_indices)
+ n_revisit_features.append(n_features)
+ revisit_coverages.append(n_features / len(revisit_indices) if revisit_indices else 0.0)
+ revisit_scores.append(
+ (
+ sum(frame_scores[index] for index in feature_indices) / n_features
+ if n_features and references and phase.is_scorable
+ else 0.0
+ )
+ )
+ return ObjectAppearanceResult(
+ phase=phase,
+ frame_scores=frame_scores,
+ similarity=similarity,
+ appearance_coverage=coverage,
+ n_reference_features=len(references),
+ n_return_features=n_return_features,
+ revisit_scores=tuple(revisit_scores),
+ revisit_coverages=tuple(revisit_coverages),
+ n_revisit_features=tuple(n_revisit_features),
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/evaluator.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/evaluator.py
new file mode 100644
index 000000000..403d69ca4
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/evaluator.py
@@ -0,0 +1,142 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Shared Object tracking and appearance analysis, scoped to one evaluation context."""
+
+from pathlib import Path
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+from ..features import MaskedDinoV2Features, get_dino_model
+from ..revisit_protocol import MAX_SHORT_MISSED_FRAMES, infer_target_phase
+from .appearance_features import analyze_object_appearance
+from .mask_alignment import align_mask_to_generated_frame
+from .object_redetector import GroundingDinoClipObjectRedetector, object_query_crop
+from .object_tracker import TransformersSam2ObjectTracker
+from .preparation import load_source, source_path
+from .protocol import load_object_entity_protocol
+from .schemas import ObjectAppearanceAnalysis, ObjectTrackAnalysis
+
+
+def _tracker(context, weight_metric="object_appearance"):
+ config = context.metric_config("object_appearance")
+ checkpoint = str(
+ config.get("sam2_checkpoint") or context.cache_dir / "metrics" / weight_metric / "sam2-hiera-large"
+ )
+ cache_size = config.get("sam2_vision_cache_size", 1)
+ return context.get_resource(
+ "object:tracker",
+ lambda: TransformersSam2ObjectTracker(
+ checkpoint=checkpoint, device=context.device, vision_cache_size=cache_size
+ ),
+ )
+
+
+def _redetector(context, weight_metric="object_appearance"):
+ config = context.metric_config("object_appearance")
+ checkpoint = str(
+ config.get("grounding_dino_checkpoint") or context.cache_dir / "metrics" / weight_metric / "grounding-dino-base"
+ )
+ reid = str(config.get("clip_reid_checkpoint") or context.cache_dir / "metrics" / weight_metric / "ViT-B-32.pt")
+ # Forward explicit overrides; defaults live in the backend.
+ fields = (
+ "search_stride",
+ "batch_size",
+ "box_threshold",
+ "text_threshold",
+ "nms_threshold",
+ "candidate_limit",
+ "reid_batch_size",
+ "reid_similarity_threshold",
+ "reid_high_confidence_threshold",
+ "reid_min_similarity_margin",
+ "min_confirmations",
+ "max_sample_gap",
+ "min_box_area_ratio",
+ "max_box_area_ratio",
+ )
+ options = {key: config[f"redetection_{key}"] for key in fields if f"redetection_{key}" in config}
+ if "redetection_reid_crop_context" in config:
+ options["reid_crop_context_fraction"] = config["redetection_reid_crop_context"]
+ return context.get_resource(
+ "object:redetector",
+ lambda: GroundingDinoClipObjectRedetector(
+ grounding_dino_checkpoint=checkpoint, reid_checkpoint=reid, device=context.device, **options
+ ),
+ )
+
+
+def _feature_extractor(context):
+ config = context.metric_config("object_appearance")
+ checkpoint = str(config.get("dino_checkpoint") or context.cache_dir / "metrics/object_appearance/dinov2-large")
+ return context.get_resource(
+ "object:dino",
+ lambda: MaskedDinoV2Features(model=get_dino_model(context, checkpoint), device=context.device, batch_size=1),
+ )
+
+
+def _visibility_segments(observations):
+ segments = []
+ for index in sorted(i for i, observation in observations.items() if observation.present):
+ if segments and segments[-1][1] + 1 == index:
+ segments[-1] = (segments[-1][0], index)
+ else:
+ segments.append((index, index))
+ return tuple(segments)
+
+
+def object_track_analysis_for_item(
+ item, context, *, tracker=None, redetector=None, reader=None, weight_metric="object_appearance"
+):
+ # The orchestrator clears this case cache after both metrics finish. Context
+ # configuration/model instances stay fixed throughout the evaluation run.
+ def create():
+ video_path = str(Path(item["video"]).resolve())
+ image_path = source_path(item, "condition_image")
+ mask_path = source_path(item, "target_object_mask_first_frame")
+ video_reader = reader or VideoReader()
+ info = video_reader.probe(video_path)
+ protocol = load_object_entity_protocol(
+ item, total_frames=info.total_frames, condition_image_path=image_path, target_mask_path=mask_path
+ )
+ condition_rgb, source_mask = load_source(image_path, mask_path)
+ decoded = video_reader.read_indices(video_path, list(protocol.required_frame_indices))
+ decoded_frames = {record.frame_idx: record.rgb for record in decoded}
+ frames = [decoded_frames[index] for index in protocol.required_frame_indices]
+ alignment = align_mask_to_generated_frame(condition_rgb, source_mask, frames[0])
+ if not alignment.mask.any():
+ raise DataInvalidError("source mask is empty after alignment to the generated first frame")
+ query = object_query_crop(frames[0], alignment.mask)
+ prompts = (redetector or _redetector(context, weight_metric)).detect(protocol.target_description, query, frames)
+ observations = (tracker or _tracker(context, weight_metric)).track(frames, alignment.mask, prompts)
+ gap = context.metric_config("object_appearance").get("max_revisit_gap_frames", MAX_SHORT_MISSED_FRAMES)
+ phase = infer_target_phase(
+ target_id=0,
+ total_frames=protocol.total_frames,
+ matched_frame_indices=[i for i, observation in observations.items() if observation.present],
+ max_revisit_gap_frames=gap,
+ expected_revisits=protocol.expected_revisits,
+ )
+ return ObjectTrackAnalysis(
+ protocol=protocol,
+ phase=phase,
+ observations=observations,
+ decoded_frames=decoded_frames,
+ video_path=video_path,
+ alignment_method=alignment.method,
+ frame0_alignment_score=alignment.score,
+ redetection_prompts=prompts,
+ visibility_segments=_visibility_segments(observations),
+ max_revisit_gap_frames=gap,
+ )
+
+ return context.get_cached(("object:track", item["item_id"]), create)
+
+
+def object_appearance_analysis_for_item(
+ item, context, *, tracker=None, redetector=None, feature_extractor=None, reader=None
+):
+ track = object_track_analysis_for_item(item, context, tracker=tracker, redetector=redetector, reader=reader)
+ result = analyze_object_appearance(track, feature_extractor or _feature_extractor(context))
+ return ObjectAppearanceAnalysis(track=track, result=result)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/geometry_consistency.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/geometry_consistency.py
new file mode 100644
index 000000000..986d17a4b
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/geometry_consistency.py
@@ -0,0 +1,180 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Canonical silhouette comparison for returned object shapes."""
+
+from __future__ import annotations
+
+import math
+
+import numpy as np
+
+from .schemas import ObjectGeometryResult, ObjectTrackAnalysis
+
+DEFAULT_CANONICAL_SIZE = 128
+DEFAULT_CANONICAL_PADDING = 8
+
+
+def canonicalize_object_mask(
+ mask: np.ndarray,
+ *,
+ canvas_size: int = DEFAULT_CANONICAL_SIZE,
+ padding: int = DEFAULT_CANONICAL_PADDING,
+) -> np.ndarray | None:
+ """Center a binary mask on a square canvas using uniform-only scaling.
+
+ Translation and global image scale are removed, while aspect ratio and
+ local silhouette deformation are preserved.
+ """
+
+ if mask.ndim != 2:
+ raise ValueError("object shape mask must be 2-D")
+ if canvas_size <= 0:
+ raise ValueError("canonical canvas size must be positive")
+ if padding < 0 or 2 * padding >= canvas_size:
+ raise ValueError("canonical padding must leave a non-empty canvas interior")
+
+ binary = np.asarray(mask, dtype=bool)
+ if not binary.any():
+ return None
+ y_coordinates, x_coordinates = np.where(binary)
+ crop = binary[
+ int(y_coordinates.min()) : int(y_coordinates.max()) + 1,
+ int(x_coordinates.min()) : int(x_coordinates.max()) + 1,
+ ]
+ crop_height, crop_width = crop.shape
+ usable_size = canvas_size - 2 * padding
+ scale = usable_size / float(max(crop_height, crop_width))
+ target_height = max(1, min(usable_size, int(round(crop_height * scale))))
+ target_width = max(1, min(usable_size, int(round(crop_width * scale))))
+
+ source_y = np.minimum(
+ (np.arange(target_height) * crop_height // target_height),
+ crop_height - 1,
+ )
+ source_x = np.minimum(
+ (np.arange(target_width) * crop_width // target_width),
+ crop_width - 1,
+ )
+ resized = crop[np.ix_(source_y, source_x)]
+ canvas = np.zeros((canvas_size, canvas_size), dtype=bool)
+ top = (canvas_size - target_height) // 2
+ left = (canvas_size - target_width) // 2
+ canvas[top : top + target_height, left : left + target_width] = resized
+ return canvas
+
+
+def mask_iou(first: np.ndarray, second: np.ndarray) -> float:
+ if first.shape != second.shape:
+ raise ValueError("canonical masks must have the same shape")
+ union = np.logical_or(first, second).sum()
+ if union == 0:
+ return 0.0
+ intersection = np.logical_and(first, second).sum()
+ return float(intersection / union)
+
+
+def analyze_object_geometry(
+ track: ObjectTrackAnalysis,
+ *,
+ canonical_size: int = DEFAULT_CANONICAL_SIZE,
+ canonical_padding: int = DEFAULT_CANONICAL_PADDING,
+) -> ObjectGeometryResult:
+ phase = track.phase
+ frame_scores = {index: 0.0 for index in phase.return_indices}
+ reference_indices: list[int] = []
+ reference_masks: list[np.ndarray] = []
+ reference_areas: list[float] = []
+ for frame_index in phase.visible_indices:
+ observation = track.observations[frame_index]
+ if not observation.present or observation.mask is None or not observation.mask_valid:
+ continue
+ canonical = canonicalize_object_mask(
+ observation.mask,
+ canvas_size=canonical_size,
+ padding=canonical_padding,
+ )
+ if canonical is None:
+ continue
+ reference_indices.append(frame_index)
+ reference_masks.append(canonical)
+ reference_areas.append(float(np.asarray(observation.mask, dtype=bool).sum()))
+
+ returned_masks: dict[int, np.ndarray] = {}
+ area_drifts_by_frame: dict[int, float] = {}
+ for frame_index in phase.return_indices:
+ observation = track.observations[frame_index]
+ if not observation.present or observation.mask is None or not observation.mask_valid:
+ continue
+ canonical_candidate = canonicalize_object_mask(
+ observation.mask,
+ canvas_size=canonical_size,
+ padding=canonical_padding,
+ )
+ if canonical_candidate is None:
+ continue
+ returned_masks[frame_index] = canonical_candidate
+ if not reference_masks:
+ continue
+ similarities = [mask_iou(reference, canonical_candidate) for reference in reference_masks]
+ best_reference = int(np.argmax(similarities))
+ score = similarities[best_reference]
+ candidate_area = float(np.asarray(observation.mask, dtype=bool).sum())
+ area_ratio = max(
+ candidate_area / reference_areas[best_reference],
+ np.finfo(float).tiny,
+ )
+ frame_scores[frame_index] = score
+ area_drifts_by_frame[frame_index] = abs(math.log(area_ratio))
+
+ return_frame_count = len(phase.return_indices)
+ n_return_masks = len(returned_masks)
+ similarity = (
+ sum(frame_scores[index] for index in returned_masks) / n_return_masks
+ if n_return_masks and reference_masks and phase.is_scorable
+ else 0.0
+ )
+ coverage = n_return_masks / return_frame_count if return_frame_count else 0.0
+ all_area_drifts = list(area_drifts_by_frame.values())
+ area_ratio_drift = float(np.median(all_area_drifts)) if all_area_drifts else 0.0
+ revisit_scores: list[float] = []
+ revisit_coverages: list[float] = []
+ n_revisit_masks: list[int] = []
+ revisit_area_ratio_drifts: list[float] = []
+ for revisit_indices in phase.revisit_indices:
+ valid_indices = [index for index in revisit_indices if index in returned_masks]
+ n_masks = len(valid_indices)
+ n_revisit_masks.append(n_masks)
+ revisit_coverages.append(n_masks / len(revisit_indices) if revisit_indices else 0.0)
+ revisit_scores.append(
+ (
+ sum(frame_scores[index] for index in valid_indices) / n_masks
+ if n_masks and reference_masks and phase.is_scorable
+ else 0.0
+ )
+ )
+ revisit_drifts = [area_drifts_by_frame[index] for index in valid_indices if index in area_drifts_by_frame]
+ revisit_area_ratio_drifts.append(float(np.median(revisit_drifts)) if revisit_drifts else 0.0)
+ return ObjectGeometryResult(
+ phase=phase,
+ reference_frame_indices=tuple(reference_indices),
+ frame_scores=frame_scores,
+ similarity=similarity,
+ geometry_coverage=coverage,
+ area_ratio_drift=area_ratio_drift,
+ n_reference_masks=len(reference_masks),
+ n_return_masks=n_return_masks,
+ revisit_scores=tuple(revisit_scores),
+ revisit_coverages=tuple(revisit_coverages),
+ n_revisit_masks=tuple(n_revisit_masks),
+ revisit_area_ratio_drifts=tuple(revisit_area_ratio_drifts),
+ )
+
+
+__all__ = [
+ "DEFAULT_CANONICAL_PADDING",
+ "DEFAULT_CANONICAL_SIZE",
+ "analyze_object_geometry",
+ "canonicalize_object_mask",
+ "mask_iou",
+]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/mask_alignment.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/mask_alignment.py
new file mode 100644
index 000000000..56366e420
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/mask_alignment.py
@@ -0,0 +1,131 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Align a source-frame object mask to a generated video's first frame."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass(frozen=True)
+class MaskAlignment:
+ method: str
+ score: float
+ mask: np.ndarray
+
+
+def _resize_pair(
+ image: np.ndarray,
+ mask: np.ndarray,
+ *,
+ width: int,
+ height: int,
+) -> tuple[np.ndarray, np.ndarray]:
+ import cv2
+
+ resized_image = cv2.resize(image, (width, height), interpolation=cv2.INTER_LINEAR)
+ resized_mask = cv2.resize(
+ mask.astype(np.uint8),
+ (width, height),
+ interpolation=cv2.INTER_NEAREST,
+ ).astype(bool)
+ return resized_image, resized_mask
+
+
+def _center_crop(
+ image: np.ndarray,
+ mask: np.ndarray,
+ *,
+ destination_aspect: float,
+) -> tuple[np.ndarray, np.ndarray]:
+ height, width = image.shape[:2]
+ source_aspect = width / float(height)
+ if source_aspect > destination_aspect:
+ crop_width = max(1, min(width, int(round(height * destination_aspect))))
+ left = (width - crop_width) // 2
+ return (
+ image[:, left : left + crop_width],
+ mask[:, left : left + crop_width],
+ )
+ crop_height = max(1, min(height, int(round(width / destination_aspect))))
+ top = (height - crop_height) // 2
+ return (
+ image[top : top + crop_height],
+ mask[top : top + crop_height],
+ )
+
+
+def _letterbox_pair(
+ image: np.ndarray,
+ mask: np.ndarray,
+ *,
+ width: int,
+ height: int,
+) -> tuple[np.ndarray, np.ndarray]:
+ source_height, source_width = image.shape[:2]
+ scale = min(width / float(source_width), height / float(source_height))
+ scaled_width = max(1, int(round(source_width * scale)))
+ scaled_height = max(1, int(round(source_height * scale)))
+ scaled_image, scaled_mask = _resize_pair(
+ image,
+ mask,
+ width=scaled_width,
+ height=scaled_height,
+ )
+ canvas = np.zeros((height, width, 3), dtype=np.uint8)
+ mask_canvas = np.zeros((height, width), dtype=bool)
+ left = (width - scaled_width) // 2
+ top = (height - scaled_height) // 2
+ canvas[top : top + scaled_height, left : left + scaled_width] = scaled_image
+ mask_canvas[top : top + scaled_height, left : left + scaled_width] = scaled_mask
+ return canvas, mask_canvas
+
+
+def _alignment_score(candidate: np.ndarray, generated: np.ndarray) -> float:
+ difference = np.abs(candidate.astype(np.float32) - generated.astype(np.float32))
+ return float(np.clip(1.0 - difference.mean() / 255.0, 0.0, 1.0))
+
+
+def align_mask_to_generated_frame(
+ condition_rgb: np.ndarray,
+ source_mask: np.ndarray,
+ generated_frame_rgb: np.ndarray,
+) -> MaskAlignment:
+ if condition_rgb.ndim != 3 or condition_rgb.shape[2] != 3:
+ raise ValueError("condition image must be an RGB image")
+ if source_mask.shape != condition_rgb.shape[:2]:
+ raise ValueError("target object mask must match the condition image size")
+ if generated_frame_rgb.ndim != 3 or generated_frame_rgb.shape[2] != 3:
+ raise ValueError("generated first frame must be an RGB image")
+
+ height, width = generated_frame_rgb.shape[:2]
+ resized = _resize_pair(condition_rgb, source_mask, width=width, height=height)
+ cropped_source = _center_crop(
+ condition_rgb,
+ source_mask,
+ destination_aspect=width / float(height),
+ )
+ center_cropped = _resize_pair(
+ *cropped_source,
+ width=width,
+ height=height,
+ )
+ letterboxed = _letterbox_pair(
+ condition_rgb,
+ source_mask,
+ width=width,
+ height=height,
+ )
+ candidates = (
+ ("resize", resized),
+ ("center_crop_resize", center_cropped),
+ ("letterbox_resize", letterboxed),
+ )
+ method, (_, aligned_mask), score = max(
+ ((name, pair, _alignment_score(pair[0], generated_frame_rgb)) for name, pair in candidates),
+ key=lambda value: value[2],
+ )
+ return MaskAlignment(method=method, score=score, mask=aligned_mask)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/metric.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/metric.py
new file mode 100644
index 000000000..0fdc756b0
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/metric.py
@@ -0,0 +1,222 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Single-target Object Appearance and Geometry with shared tracking."""
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import (
+ DataInvalidError,
+ EvaluationError,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.aggregation import (
+ aggregate_video_summary,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.results import (
+ ItemResult,
+ ResultStatus,
+ UnitResult,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+from ..revisit_protocol import MAX_SHORT_MISSED_FRAMES, MIN_REAPPEAR_MATCH_RUN, REVISIT_BOUNDARY_BUFFER_FRAMES
+from .evaluator import object_appearance_analysis_for_item, object_track_analysis_for_item
+from .geometry_consistency import DEFAULT_CANONICAL_PADDING, DEFAULT_CANONICAL_SIZE, analyze_object_geometry
+from .object_tracker import (
+ MAX_MASK_AREA_RATIO,
+ MIN_MASK_AREA_RATIO,
+ MIN_MASK_AREA_RELATIVE_TO_SEED,
+ PRESENCE_LOGIT_THRESHOLD,
+)
+from .revisit import official_revisit_score, revisit_metadata, revisit_score_fields
+
+
+def _frame_boundary_contact_ratio(track, indices) -> float:
+ contacts = []
+ for index in indices:
+ observation = track.observations[index]
+ if not observation.present or observation.mask is None:
+ continue
+ mask = observation.mask
+ contacts.append(bool(mask[0].any() or mask[-1].any() or mask[:, 0].any() or mask[:, -1].any()))
+ return float(sum(contacts) / len(contacts)) if contacts else 0.0
+
+
+class _ObjectMetric(Metric):
+ geometry = False
+
+ def __init__(self, *, tracker=None, redetector=None, feature_extractor=None, reader=None):
+ self.tracker = tracker
+ self.redetector = redetector
+ self.feature_extractor = feature_extractor
+ self.reader = reader or VideoReader()
+
+ def evaluate(self, sample: dict, context) -> list[UnitResult]:
+ if sample.get("category") not in (None, "object"):
+ return [UnitResult(unit_id="target_0000", status=ResultStatus.NOT_APPLICABLE)]
+ error = sample.get("object_preparation_error")
+ if error:
+ exception = DataInvalidError if error["status"] == "data_invalid" else EvaluationError
+ raise exception(error["message"])
+ kwargs = dict(tracker=self.tracker, redetector=self.redetector, reader=self.reader)
+ extra_scores, extra_metadata = {}, {}
+ if self.geometry:
+ track = object_track_analysis_for_item(sample, context, weight_metric=self.name, **kwargs)
+ config = context.metric_config(self.name)
+ size = int(config.get("canonical_canvas_size", DEFAULT_CANONICAL_SIZE))
+ padding = int(config.get("canonical_padding", DEFAULT_CANONICAL_PADDING))
+ result = analyze_object_geometry(track, canonical_size=size, canonical_padding=padding)
+ coverage = result.geometry_coverage
+ counts = result.n_revisit_masks
+ count_key = "n_return_masks"
+ extra_scores = {
+ "area_ratio_drift": result.area_ratio_drift,
+ "frame_boundary_contact_ratio": _frame_boundary_contact_ratio(track, result.phase.return_indices),
+ }
+ extra_metadata = {
+ "reference_frame_indices": list(result.reference_frame_indices),
+ "n_reference_masks": result.n_reference_masks,
+ "n_return_masks": result.n_return_masks,
+ "geometry_protocol": {
+ "canonical_canvas_size": size,
+ "canonical_padding": padding,
+ "alignment": "bbox_crop_center_uniform_scale",
+ "reference_matching": "max_iou_over_frozen_v_library",
+ "aggregation": "mean_valid_frames_per_revisit_macro",
+ },
+ }
+ else:
+ analysis = object_appearance_analysis_for_item(
+ sample, context, feature_extractor=self.feature_extractor, **kwargs
+ )
+ track, result = analysis.track, analysis.result
+ coverage = result.appearance_coverage
+ counts = result.n_revisit_features
+ count_key = "n_return_features"
+ extra_metadata = {
+ "n_reference_features": result.n_reference_features,
+ "n_return_features": result.n_return_features,
+ }
+
+ phase = result.phase
+ expected = track.protocol.expected_revisits
+ kind = "geometry" if self.geometry else "appearance"
+ scores = {
+ "score": official_revisit_score(result.revisit_scores, expected_revisits=expected),
+ f"{kind}_similarity": result.similarity,
+ f"{kind}_coverage": coverage,
+ **extra_scores,
+ **revisit_score_fields(result.revisit_scores, result.revisit_coverages, expected_revisits=expected),
+ }
+ revisits = revisit_metadata(
+ phase,
+ result.revisit_scores,
+ result.revisit_coverages,
+ counts,
+ count_key=count_key,
+ expected_revisits=expected,
+ )
+ if self.geometry:
+ for index, revisit in enumerate(revisits):
+ indices = phase.revisit_indices[index] if index < len(phase.revisit_indices) else ()
+ revisit["frame_boundary_contact_ratio"] = _frame_boundary_contact_ratio(track, indices)
+ revisit["area_ratio_drift"] = (
+ result.revisit_area_ratio_drifts[index] if index < len(result.revisit_area_ratio_drifts) else 0.0
+ )
+ phase_roles = {index: "V" for index in phase.visible_indices}
+ for index, indices in enumerate(phase.revisit_indices, 1):
+ phase_roles.update({frame: f"R{index}" for frame in indices})
+ events = []
+ for prompt in track.redetection_prompts:
+ outcome = "rejected_or_unstable"
+ for index, (start, end) in enumerate(track.visibility_segments):
+ if start <= prompt.frame_index <= end:
+ outcome = "already_tracked" if index == 0 else "accepted_reacquisition"
+ break
+ events.append(
+ {
+ "frame_index": prompt.frame_index,
+ "box_xyxy": list(prompt.box_xyxy),
+ "detector_score": prompt.detector_score,
+ "reid_similarity": prompt.reid_similarity,
+ "runner_up_reid_similarity": prompt.runner_up_reid_similarity,
+ "candidate_count": prompt.candidate_count,
+ "phase_role": phase_roles.get(prompt.frame_index, "D"),
+ "tracking_outcome": outcome,
+ }
+ )
+ target = {
+ "target_id": 0,
+ "phase_status": phase.status,
+ "V": list(phase.visible_indices),
+ "D": list(phase.departure_indices),
+ "R": list(phase.return_indices),
+ "revisits": revisits,
+ f"{kind}_score": scores["score"],
+ f"{kind}_similarity": result.similarity,
+ f"{kind}_coverage": coverage,
+ **extra_scores,
+ **extra_metadata,
+ }
+ return [
+ UnitResult(
+ unit_id="target_0000",
+ unit_type=f"target_object_{kind}",
+ scores=scores,
+ metadata={
+ "video_path": track.video_path,
+ "n_targets": 1,
+ "condition_image_path": track.protocol.condition_image_path,
+ "target_mask_path": track.protocol.target_mask_path,
+ "target_description": track.protocol.target_description,
+ "expected_revisits": expected,
+ "revisit_mode": "auto" if expected is None else "fixed",
+ "alignment_method": track.alignment_method,
+ "frame0_alignment_score": track.frame0_alignment_score,
+ "phase_source": "grounding_dino_clip_redetection_with_multitracklet_sam2",
+ "phase_inference": {
+ "presence_logit_threshold": PRESENCE_LOGIT_THRESHOLD,
+ "min_mask_area_ratio": MIN_MASK_AREA_RATIO,
+ "max_mask_area_ratio": MAX_MASK_AREA_RATIO,
+ "min_mask_area_relative_to_seed": MIN_MASK_AREA_RELATIVE_TO_SEED,
+ "max_short_missed_frames": MAX_SHORT_MISSED_FRAMES,
+ "min_reappear_match_run": MIN_REAPPEAR_MATCH_RUN,
+ "max_revisit_gap_frames": track.max_revisit_gap_frames,
+ "revisit_allocator": "observed_return_components"
+ if expected is None
+ else "boundary_presence_components",
+ "revisit_boundary_buffer_frames": None if expected is None else REVISIT_BOUNDARY_BUFFER_FRAMES,
+ },
+ "visibility_segments": [list(segment) for segment in track.visibility_segments],
+ "redetection_events": events,
+ "per_target": [target],
+ },
+ )
+ ]
+
+ def aggregate_item(self, sample, units):
+ if len(units) != 1:
+ raise EvaluationError(f"{self.name} requires exactly one target unit")
+ unit = units[0]
+ return ItemResult(
+ item_id=sample["item_id"],
+ model_id=sample.get("model_id"),
+ scores=dict(unit.scores),
+ num_units=1,
+ status=unit.status,
+ error=unit.error,
+ metadata=dict(unit.metadata),
+ )
+
+ def aggregate_summary(self, items):
+ return aggregate_video_summary(items)
+
+
+class ObjectAppearanceMetric(_ObjectMetric):
+ name = "object_appearance"
+ description = "Masked DINOv2-Large appearance consistency across object revisits."
+
+
+class ObjectGeometryMetric(_ObjectMetric):
+ name = "object_geometry"
+ description = "Canonical silhouette IoU across object revisits, preserving the reference shape score."
+ geometry = True
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/object_redetector.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/object_redetector.py
new file mode 100644
index 000000000..de3b3da79
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/object_redetector.py
@@ -0,0 +1,527 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Text-prompted object re-detection independent from appearance scoring."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Protocol
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+from .schemas import ObjectRedetectionPrompt
+
+REDETECTION_SEARCH_STRIDE = 4
+REDETECTION_BATCH_SIZE = 8
+REDETECTION_BOX_THRESHOLD = 0.30
+REDETECTION_TEXT_THRESHOLD = 0.25
+REDETECTION_NMS_THRESHOLD = 0.50
+REDETECTION_CANDIDATE_LIMIT = 64
+REID_BATCH_SIZE = 64
+REID_SIMILARITY_THRESHOLD = 0.75
+REID_HIGH_CONFIDENCE_THRESHOLD = 0.90
+REID_MIN_SIMILARITY_MARGIN = 0.04
+REID_CROP_CONTEXT_FRACTION = 0.10
+REDETECTION_MIN_CONFIRMATIONS = 2
+REDETECTION_MAX_SAMPLE_GAP = 2
+REDETECTION_MIN_BOX_AREA_RATIO = 0.0005
+REDETECTION_MAX_BOX_AREA_RATIO = 0.70
+REDETECTION_MAX_CENTER_DISTANCE_RATIO = 0.35
+REDETECTION_MAX_AREA_CHANGE = 10.0
+QUERY_CONTEXT_FRACTION = 0.10
+
+
+@dataclass(frozen=True)
+class _DetectionCandidate:
+ detector_score: float
+ box_xyxy: tuple[float, float, float, float]
+
+
+def reid_match_is_accepted(
+ top_similarity: float,
+ runner_up_similarity: float | None,
+ *,
+ similarity_threshold: float = REID_SIMILARITY_THRESHOLD,
+ high_confidence_threshold: float = REID_HIGH_CONFIDENCE_THRESHOLD,
+ min_similarity_margin: float = REID_MIN_SIMILARITY_MARGIN,
+) -> bool:
+ """Accept an absolute-high match or a lower match with usable evidence.
+
+ A lone candidate has no relative-margin evidence, but still has absolute
+ similarity evidence and must pass the downstream temporal confirmation.
+ """
+
+ if top_similarity < similarity_threshold:
+ return False
+ if top_similarity >= high_confidence_threshold:
+ return True
+ return runner_up_similarity is None or top_similarity - runner_up_similarity >= min_similarity_margin
+
+
+class ObjectRedetectorBackend(Protocol):
+ def detect(
+ self,
+ text_prompt: str,
+ query_rgb: np.ndarray,
+ frames_rgb: Sequence[np.ndarray],
+ ) -> tuple[ObjectRedetectionPrompt, ...]: ...
+
+
+def object_query_crop(
+ image_rgb: np.ndarray,
+ mask: np.ndarray,
+ *,
+ context_fraction: float = QUERY_CONTEXT_FRACTION,
+) -> np.ndarray:
+ """Crop the prompted object while retaining a small amount of natural context."""
+
+ if image_rgb.ndim != 3 or image_rgb.shape[2] != 3:
+ raise ValueError("object query image must be RGB")
+ if mask.shape != image_rgb.shape[:2] or not mask.any():
+ raise ValueError("object query mask must be non-empty and match the image")
+ if context_fraction < 0.0:
+ raise ValueError("context_fraction must be non-negative")
+
+ ys, xs = np.nonzero(mask)
+ x1, x2 = int(xs.min()), int(xs.max()) + 1
+ y1, y2 = int(ys.min()), int(ys.max()) + 1
+ padding = max(2, int(round(max(x2 - x1, y2 - y1) * context_fraction)))
+ height, width = mask.shape
+ x1 = max(0, x1 - padding)
+ y1 = max(0, y1 - padding)
+ x2 = min(width, x2 + padding)
+ y2 = min(height, y2 + padding)
+ return np.ascontiguousarray(image_rgb[y1:y2, x1:x2])
+
+
+def _box_center_and_area(
+ box: tuple[float, float, float, float],
+) -> tuple[float, float, float]:
+ x1, y1, x2, y2 = box
+ return (x1 + x2) * 0.5, (y1 + y2) * 0.5, (x2 - x1) * (y2 - y1)
+
+
+def _boxes_share_object_hypothesis(
+ left: tuple[float, float, float, float],
+ right: tuple[float, float, float, float],
+ *,
+ intersection_over_smaller_threshold: float = 0.50,
+) -> bool:
+ """Return whether two boxes are alternate crops of one spatial object."""
+
+ left_x1, left_y1, left_x2, left_y2 = left
+ right_x1, right_y1, right_x2, right_y2 = right
+ intersection_width = max(0.0, min(left_x2, right_x2) - max(left_x1, right_x1))
+ intersection_height = max(
+ 0.0,
+ min(left_y2, right_y2) - max(left_y1, right_y1),
+ )
+ intersection_area = intersection_width * intersection_height
+ if intersection_area <= 0.0:
+ return False
+ left_area = max(0.0, left_x2 - left_x1) * max(0.0, left_y2 - left_y1)
+ right_area = max(0.0, right_x2 - right_x1) * max(
+ 0.0,
+ right_y2 - right_y1,
+ )
+ smaller_area = min(left_area, right_area)
+ return smaller_area > 0.0 and intersection_area / smaller_area >= intersection_over_smaller_threshold
+
+
+def _temporally_compatible(
+ previous: ObjectRedetectionPrompt,
+ current: ObjectRedetectionPrompt,
+ *,
+ frame_height: int,
+ frame_width: int,
+ max_center_distance_ratio: float,
+ max_area_change: float,
+) -> bool:
+ previous_x, previous_y, previous_area = _box_center_and_area(previous.box_xyxy)
+ current_x, current_y, current_area = _box_center_and_area(current.box_xyxy)
+ diagonal = max(1.0, float(np.hypot(frame_width, frame_height)))
+ center_distance = float(np.hypot(current_x - previous_x, current_y - previous_y) / diagonal)
+ if center_distance > max_center_distance_ratio:
+ return False
+ smaller_area = min(previous_area, current_area)
+ larger_area = max(previous_area, current_area)
+ return smaller_area > 0.0 and larger_area / smaller_area <= max_area_change
+
+
+def confirm_redetection_prompts(
+ prompts: Sequence[ObjectRedetectionPrompt],
+ *,
+ frame_height: int,
+ frame_width: int,
+ search_stride: int = REDETECTION_SEARCH_STRIDE,
+ min_confirmations: int = REDETECTION_MIN_CONFIRMATIONS,
+ max_sample_gap: int = REDETECTION_MAX_SAMPLE_GAP,
+ max_center_distance_ratio: float = REDETECTION_MAX_CENTER_DISTANCE_RATIO,
+ max_area_change: float = REDETECTION_MAX_AREA_CHANGE,
+) -> tuple[ObjectRedetectionPrompt, ...]:
+ """Keep only temporally persistent ReID matches, without using score-time DINO."""
+
+ if search_stride <= 0:
+ raise ValueError("search_stride must be positive")
+ if min_confirmations <= 0:
+ raise ValueError("min_confirmations must be positive")
+ if max_sample_gap <= 0:
+ raise ValueError("max_sample_gap must be positive")
+ ordered = sorted(prompts, key=lambda prompt: prompt.frame_index)
+ if not ordered:
+ return ()
+
+ runs: list[list[ObjectRedetectionPrompt]] = []
+ current_run = [ordered[0]]
+ maximum_gap = search_stride * max_sample_gap
+ for prompt in ordered[1:]:
+ previous = current_run[-1]
+ compatible = 0 < prompt.frame_index - previous.frame_index <= maximum_gap and _temporally_compatible(
+ previous,
+ prompt,
+ frame_height=frame_height,
+ frame_width=frame_width,
+ max_center_distance_ratio=max_center_distance_ratio,
+ max_area_change=max_area_change,
+ )
+ if compatible:
+ current_run.append(prompt)
+ else:
+ runs.append(current_run)
+ current_run = [prompt]
+ runs.append(current_run)
+ return tuple(prompt for run in runs if len(run) >= min_confirmations for prompt in run)
+
+
+class GroundingDinoClipObjectRedetector:
+ """Generate candidates with Grounding DINO text-prompt detection and confirm with independent CLIP ReID."""
+
+ def __init__(
+ self,
+ *,
+ grounding_dino_checkpoint: str,
+ reid_checkpoint: str,
+ device: str,
+ search_stride: int = REDETECTION_SEARCH_STRIDE,
+ batch_size: int = REDETECTION_BATCH_SIZE,
+ box_threshold: float = REDETECTION_BOX_THRESHOLD,
+ text_threshold: float = REDETECTION_TEXT_THRESHOLD,
+ nms_threshold: float = REDETECTION_NMS_THRESHOLD,
+ candidate_limit: int = REDETECTION_CANDIDATE_LIMIT,
+ reid_batch_size: int = REID_BATCH_SIZE,
+ reid_similarity_threshold: float = REID_SIMILARITY_THRESHOLD,
+ reid_high_confidence_threshold: float = (REID_HIGH_CONFIDENCE_THRESHOLD),
+ reid_min_similarity_margin: float = REID_MIN_SIMILARITY_MARGIN,
+ reid_crop_context_fraction: float = REID_CROP_CONTEXT_FRACTION,
+ min_confirmations: int = REDETECTION_MIN_CONFIRMATIONS,
+ max_sample_gap: int = REDETECTION_MAX_SAMPLE_GAP,
+ min_box_area_ratio: float = REDETECTION_MIN_BOX_AREA_RATIO,
+ max_box_area_ratio: float = REDETECTION_MAX_BOX_AREA_RATIO,
+ ) -> None:
+ try:
+ import torch
+ from torchvision.ops import roi_align
+ from transformers import (
+ AutoProcessor,
+ GroundingDinoForObjectDetection,
+ )
+
+ from worldfoundry.base_models.perception_core.general_perception import openai_clip as clip
+ except ImportError as error:
+ raise EvaluationError(
+ "Object Appearance re-detection requires Grounding DINO and "
+ "openai-clip; install the WorldMemBench metric requirements"
+ ) from error
+
+ grounding_dino_checkpoint_path = Path(grounding_dino_checkpoint).expanduser().resolve()
+ reid_checkpoint_path = Path(reid_checkpoint).expanduser().resolve()
+ if not grounding_dino_checkpoint_path.exists():
+ raise EvaluationError(f"Grounding DINO checkpoint not found: {grounding_dino_checkpoint_path}")
+ if not reid_checkpoint_path.is_file():
+ raise EvaluationError(f"CLIP ReID checkpoint not found: {reid_checkpoint_path}")
+ self.processor = AutoProcessor.from_pretrained(
+ grounding_dino_checkpoint_path,
+ local_files_only=True,
+ )
+ self.model = (
+ GroundingDinoForObjectDetection.from_pretrained(
+ grounding_dino_checkpoint_path,
+ local_files_only=True,
+ )
+ .to(device)
+ .eval()
+ )
+ self.reid_model, self.reid_preprocess = clip.load(
+ str(reid_checkpoint_path),
+ device=device,
+ jit=False,
+ )
+ self.reid_model.eval()
+ self.roi_align = roi_align
+ self.reid_mean = torch.tensor(
+ [0.48145466, 0.4578275, 0.40821073],
+ device=device,
+ ).view(1, 3, 1, 1)
+ self.reid_std = torch.tensor(
+ [0.26862954, 0.26130258, 0.27577711],
+ device=device,
+ ).view(1, 3, 1, 1)
+
+ self.device = device
+ self.search_stride = search_stride
+ self.batch_size = batch_size
+ self.box_threshold = box_threshold
+ self.text_threshold = text_threshold
+ self.nms_threshold = nms_threshold
+ self.candidate_limit = candidate_limit
+ self.reid_batch_size = reid_batch_size
+ self.reid_similarity_threshold = reid_similarity_threshold
+ self.reid_high_confidence_threshold = reid_high_confidence_threshold
+ self.reid_min_similarity_margin = reid_min_similarity_margin
+ self.reid_crop_context_fraction = reid_crop_context_fraction
+ self.min_confirmations = min_confirmations
+ self.max_sample_gap = max_sample_gap
+ self.min_box_area_ratio = min_box_area_ratio
+ self.max_box_area_ratio = max_box_area_ratio
+
+ def _candidates(
+ self,
+ result: dict[str, object],
+ *,
+ frame_height: int,
+ frame_width: int,
+ ) -> tuple[_DetectionCandidate, ...]:
+ boxes_tensor = result["boxes"]
+ scores_tensor = result["scores"]
+ boxes = boxes_tensor.detach().float().cpu().numpy()
+ scores = scores_tensor.detach().float().cpu().numpy()
+ frame_area = float(frame_height * frame_width)
+ candidates: list[_DetectionCandidate] = []
+ for raw_box, raw_score in zip(boxes, scores, strict=True):
+ x1, y1, x2, y2 = (float(value) for value in raw_box)
+ x1 = min(max(x1, 0.0), float(frame_width))
+ x2 = min(max(x2, 0.0), float(frame_width))
+ y1 = min(max(y1, 0.0), float(frame_height))
+ y2 = min(max(y2, 0.0), float(frame_height))
+ area_ratio = max(0.0, x2 - x1) * max(0.0, y2 - y1) / frame_area
+ if np.isfinite(raw_score) and self.min_box_area_ratio <= area_ratio <= self.max_box_area_ratio:
+ candidates.append(
+ _DetectionCandidate(
+ detector_score=float(raw_score),
+ box_xyxy=(x1, y1, x2, y2),
+ )
+ )
+ if len(candidates) > 1:
+ import torch
+ from torchvision.ops import nms
+
+ boxes_tensor = torch.tensor(
+ [candidate.box_xyxy for candidate in candidates],
+ dtype=torch.float32,
+ )
+ scores_tensor = torch.tensor(
+ [candidate.detector_score for candidate in candidates],
+ dtype=torch.float32,
+ )
+ keep = nms(boxes_tensor, scores_tensor, self.nms_threshold)
+ candidates = [candidates[int(index)] for index in keep.tolist()]
+ candidates.sort(
+ key=lambda candidate: candidate.detector_score,
+ reverse=True,
+ )
+ return tuple(candidates[: self.candidate_limit])
+
+ def _reidentify(
+ self,
+ *,
+ query_embedding: object,
+ batch_indices: Sequence[int],
+ frames_rgb: Sequence[np.ndarray],
+ candidates_by_frame: Sequence[Sequence[_DetectionCandidate]],
+ ) -> list[ObjectRedetectionPrompt]:
+ import torch
+
+ owners: list[tuple[int, _DetectionCandidate]] = []
+ rois: list[list[float]] = []
+ for owner, (frame_index, candidates) in enumerate(zip(batch_indices, candidates_by_frame, strict=True)):
+ height, width = frames_rgb[frame_index].shape[:2]
+ for candidate in candidates:
+ x1, y1, x2, y2 = candidate.box_xyxy
+ padding = max(x2 - x1, y2 - y1) * self.reid_crop_context_fraction
+ rois.append(
+ [
+ float(owner),
+ max(0.0, x1 - padding),
+ max(0.0, y1 - padding),
+ min(float(width), x2 + padding),
+ min(float(height), y2 + padding),
+ ]
+ )
+ owners.append((owner, candidate))
+ if not rois:
+ return []
+
+ frame_tensor = torch.stack(
+ [torch.from_numpy(np.ascontiguousarray(frames_rgb[index])).permute(2, 0, 1) for index in batch_indices]
+ ).to(device=self.device, dtype=torch.float32)
+ frame_tensor = frame_tensor / 255.0
+ crop_tensor = self.roi_align(
+ frame_tensor,
+ torch.tensor(rois, device=self.device, dtype=torch.float32),
+ output_size=(224, 224),
+ spatial_scale=1.0,
+ sampling_ratio=2,
+ aligned=True,
+ )
+ crop_tensor = (crop_tensor - self.reid_mean) / self.reid_std
+
+ similarities: list[float] = []
+ with torch.inference_mode():
+ for offset in range(0, len(crop_tensor), self.reid_batch_size):
+ features = self.reid_model.encode_image(crop_tensor[offset : offset + self.reid_batch_size])
+ features = features / features.norm(
+ dim=-1,
+ keepdim=True,
+ ).clamp_min(1e-12)
+ batch_similarities = (features @ query_embedding.T).squeeze(1)
+ similarities.extend(batch_similarities.float().cpu().tolist())
+
+ ranked_by_owner: list[list[tuple[float, _DetectionCandidate]]] = [[] for _ in batch_indices]
+ for (owner, candidate), similarity in zip(
+ owners,
+ similarities,
+ strict=True,
+ ):
+ ranked_by_owner[owner].append((similarity, candidate))
+
+ prompts: list[ObjectRedetectionPrompt] = []
+ for owner, ranked in enumerate(ranked_by_owner):
+ ranked.sort(key=lambda entry: entry[0], reverse=True)
+ frame_index = batch_indices[owner]
+ if not ranked:
+ continue
+ top_similarity, top_candidate = ranked[0]
+ runner_up_similarity = next(
+ (
+ similarity
+ for similarity, candidate in ranked[1:]
+ if not _boxes_share_object_hypothesis(
+ top_candidate.box_xyxy,
+ candidate.box_xyxy,
+ )
+ ),
+ None,
+ )
+ if not reid_match_is_accepted(
+ top_similarity,
+ runner_up_similarity,
+ similarity_threshold=self.reid_similarity_threshold,
+ high_confidence_threshold=self.reid_high_confidence_threshold,
+ min_similarity_margin=self.reid_min_similarity_margin,
+ ):
+ continue
+ prompts.append(
+ ObjectRedetectionPrompt(
+ frame_index=frame_index,
+ box_xyxy=top_candidate.box_xyxy,
+ detector_score=top_candidate.detector_score,
+ reid_similarity=top_similarity,
+ runner_up_reid_similarity=runner_up_similarity,
+ candidate_count=len(ranked),
+ )
+ )
+ return prompts
+
+ def detect(
+ self,
+ text_prompt: str,
+ query_rgb: np.ndarray,
+ frames_rgb: Sequence[np.ndarray],
+ ) -> tuple[ObjectRedetectionPrompt, ...]:
+ import torch
+ from PIL import Image
+
+ if not frames_rgb:
+ return ()
+ frame_height, frame_width = frames_rgb[0].shape[:2]
+ if any(frame.shape[:2] != (frame_height, frame_width) for frame in frames_rgb):
+ raise EvaluationError("Object Appearance requires constant-size video frames")
+
+ text = text_prompt.strip()
+ if not text:
+ raise EvaluationError("Grounding DINO requires a non-empty text prompt")
+ sampled_indices = list(range(self.search_stride, len(frames_rgb), self.search_stride))
+ raw_prompts: list[ObjectRedetectionPrompt] = []
+ query_image = Image.fromarray(query_rgb)
+ with torch.inference_mode():
+ query_embedding = self.reid_model.encode_image(
+ self.reid_preprocess(query_image).unsqueeze(0).to(self.device)
+ )
+ query_embedding = query_embedding / query_embedding.norm(
+ dim=-1,
+ keepdim=True,
+ ).clamp_min(1e-12)
+
+ for offset in range(0, len(sampled_indices), self.batch_size):
+ batch_indices = sampled_indices[offset : offset + self.batch_size]
+ target_images = [Image.fromarray(frames_rgb[index]) for index in batch_indices]
+ # transformers >= 5 treats a plain str or list[str] as one sample
+ # ("one image, many phrases"); list[list[str]] is the only form
+ # that yields one text batch entry per image.
+ inputs = self.processor(
+ images=target_images,
+ text=[[text]] * len(batch_indices),
+ return_tensors="pt",
+ ).to(self.device)
+ with torch.inference_mode():
+ outputs = self.model(**inputs)
+ target_sizes = torch.tensor(
+ [[frame_height, frame_width]] * len(batch_indices),
+ device=self.device,
+ )
+ results = self.processor.post_process_grounded_object_detection(
+ outputs=outputs,
+ input_ids=inputs.input_ids,
+ threshold=self.box_threshold,
+ text_threshold=self.text_threshold,
+ target_sizes=target_sizes,
+ )
+ candidates_by_frame = [
+ self._candidates(
+ result,
+ frame_height=frame_height,
+ frame_width=frame_width,
+ )
+ for result in results
+ ]
+ raw_prompts.extend(
+ self._reidentify(
+ query_embedding=query_embedding,
+ batch_indices=batch_indices,
+ frames_rgb=frames_rgb,
+ candidates_by_frame=candidates_by_frame,
+ )
+ )
+
+ return confirm_redetection_prompts(
+ raw_prompts,
+ frame_height=frame_height,
+ frame_width=frame_width,
+ search_stride=self.search_stride,
+ min_confirmations=self.min_confirmations,
+ max_sample_gap=self.max_sample_gap,
+ )
+
+
+__all__ = [
+ "ObjectRedetectorBackend",
+ "GroundingDinoClipObjectRedetector",
+ "confirm_redetection_prompts",
+ "object_query_crop",
+ "reid_match_is_accepted",
+]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/object_tracker.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/object_tracker.py
new file mode 100644
index 000000000..868b804c4
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/object_tracker.py
@@ -0,0 +1,317 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""First-frame-mask SAM2 tracking with independent object-presence evidence."""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any, Protocol
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+from ..revisit_protocol import MAX_SHORT_MISSED_FRAMES
+from .schemas import ObjectRedetectionPrompt, ObjectTrackObservation
+
+PRESENCE_LOGIT_THRESHOLD = 0.0
+MIN_MASK_AREA_RATIO = 0.0005
+MAX_MASK_AREA_RATIO = 0.70
+MIN_MASK_AREA_RELATIVE_TO_SEED = 0.10
+MIN_REDETECTED_TRACKLET_FRAMES = 3
+
+
+def _anchor_connected_presence_indices(
+ observations: dict[int, ObjectTrackObservation],
+ *,
+ total_frames: int,
+ anchor_index: int,
+ max_short_missed_frames: int = MAX_SHORT_MISSED_FRAMES,
+) -> tuple[int, ...]:
+ presence = [bool(observations.get(index) and observations[index].present) for index in range(total_frames)]
+ index = 0
+ while index < total_frames:
+ if presence[index]:
+ index += 1
+ continue
+ start = index
+ while index < total_frames and not presence[index]:
+ index += 1
+ if start > 0 and index < total_frames and index - start <= max_short_missed_frames:
+ presence[start:index] = [True] * (index - start)
+ if not presence[anchor_index]:
+ return ()
+ start = anchor_index
+ while start > 0 and presence[start - 1]:
+ start -= 1
+ end = anchor_index
+ while end + 1 < total_frames and presence[end + 1]:
+ end += 1
+ return tuple(range(start, end + 1))
+
+
+class ObjectTrackerBackend(Protocol):
+ def track(
+ self,
+ frames_rgb: Sequence[np.ndarray],
+ first_frame_mask: np.ndarray,
+ redetection_prompts: Sequence[ObjectRedetectionPrompt] = (),
+ ) -> dict[int, ObjectTrackObservation]: ...
+
+
+class TransformersSam2ObjectTracker:
+ """Track one seeded object and preserve SAM2's occlusion/presence logit."""
+
+ def __init__(
+ self,
+ *,
+ checkpoint: str,
+ device: str,
+ presence_logit_threshold: float = PRESENCE_LOGIT_THRESHOLD,
+ min_mask_area_ratio: float = MIN_MASK_AREA_RATIO,
+ max_mask_area_ratio: float = MAX_MASK_AREA_RATIO,
+ min_mask_area_relative_to_seed: float = MIN_MASK_AREA_RELATIVE_TO_SEED,
+ dtype: str = "bfloat16",
+ vision_cache_size: int | None = 1,
+ ) -> None:
+ try:
+ import torch
+ from transformers import Sam2VideoModel, Sam2VideoProcessor
+ except ImportError as error:
+ raise EvaluationError(
+ "Object Appearance requires transformers with Sam2VideoModel; "
+ "install the WorldMemBench metric requirements"
+ ) from error
+ checkpoint_path = Path(checkpoint).expanduser().resolve()
+ if not checkpoint_path.exists():
+ raise EvaluationError(f"SAM2 checkpoint not found: {checkpoint_path}")
+ if vision_cache_size is not None and (
+ isinstance(vision_cache_size, bool) or not isinstance(vision_cache_size, int) or vision_cache_size < 1
+ ):
+ raise ValueError("sam2_vision_cache_size must be a positive integer or null (all frames)")
+ self.vision_cache_size = vision_cache_size
+ self._dtype = getattr(torch, dtype)
+ self.processor = Sam2VideoProcessor.from_pretrained(
+ checkpoint_path,
+ local_files_only=True,
+ )
+ self.model = (
+ Sam2VideoModel.from_pretrained(
+ checkpoint_path,
+ local_files_only=True,
+ torch_dtype=self._dtype,
+ )
+ .to(device)
+ .eval()
+ )
+ self.device = device
+ self.presence_logit_threshold = presence_logit_threshold
+ self.min_mask_area_ratio = min_mask_area_ratio
+ self.max_mask_area_ratio = max_mask_area_ratio
+ self.min_mask_area_relative_to_seed = min_mask_area_relative_to_seed
+
+ def _observation(
+ self,
+ output: Any,
+ *,
+ height: int,
+ width: int,
+ minimum_mask_area_ratio: float,
+ ) -> ObjectTrackObservation:
+ masks, logits = output.pred_masks, output.object_score_logits
+ if masks is None or logits is None:
+ raise EvaluationError("SAM2 must return masks and object_score_logits; check the installed version")
+ frame_index = int(output.frame_idx)
+
+ processed = self.processor.post_process_masks(
+ [masks],
+ original_sizes=[[height, width]],
+ binarize=True,
+ )[0]
+ binary = processed.detach().cpu().numpy()
+ while binary.ndim > 2:
+ binary = binary[0]
+ mask = np.asarray(binary, dtype=bool)
+ if mask.shape != (height, width):
+ raise EvaluationError(f"SAM2 returned mask shape {mask.shape}, expected {(height, width)}")
+
+ presence_logit = float(logits.detach().float().cpu().numpy().reshape(-1)[0])
+ if not math.isfinite(presence_logit):
+ raise EvaluationError("SAM2 returned a non-finite object score")
+ probability = 1.0 / (1.0 + math.exp(-max(-60.0, min(60.0, presence_logit))))
+ area_ratio = float(mask.mean())
+ mask_valid = bool(mask.any()) and minimum_mask_area_ratio <= area_ratio <= self.max_mask_area_ratio
+ present = presence_logit > self.presence_logit_threshold and mask_valid
+ return ObjectTrackObservation(
+ frame_index=frame_index,
+ mask=mask,
+ presence_logit=presence_logit,
+ presence_probability=probability,
+ mask_area_ratio=area_ratio,
+ mask_valid=mask_valid,
+ present=present,
+ )
+
+ def track(
+ self,
+ frames_rgb: Sequence[np.ndarray],
+ first_frame_mask: np.ndarray,
+ redetection_prompts: Sequence[ObjectRedetectionPrompt] = (),
+ ) -> dict[int, ObjectTrackObservation]:
+ import torch
+ from PIL import Image
+
+ if not frames_rgb:
+ return {}
+ height, width = frames_rgb[0].shape[:2]
+ if first_frame_mask.shape != (height, width) or not first_frame_mask.any():
+ raise EvaluationError("the aligned first-frame object mask is empty or has the wrong size")
+ ordered_prompts = tuple(sorted(redetection_prompts, key=lambda prompt: prompt.frame_index))
+ for prompt in ordered_prompts:
+ if prompt.frame_index <= 0 or prompt.frame_index >= len(frames_rgb):
+ raise EvaluationError("SAM2 redetection prompt frame is out of range")
+ x1, y1, x2, y2 = prompt.box_xyxy
+ if not (x2 > x1 and y2 > y1):
+ raise EvaluationError("SAM2 redetection prompt box is invalid")
+
+ minimum_mask_area_ratio = max(
+ self.min_mask_area_ratio,
+ float(first_frame_mask.mean()) * self.min_mask_area_relative_to_seed,
+ )
+ pil_frames = [Image.fromarray(frame) for frame in frames_rgb]
+ # None keeps all frames for large GPUs; the default recomputes evicted
+ # image features. Neither mode drops temporal tracking state or frames.
+ session = self.processor.init_video_session(
+ video=pil_frames,
+ inference_device=self.device,
+ max_vision_features_cache_size=self.vision_cache_size or len(pil_frames),
+ dtype=self._dtype,
+ )
+
+ def store(
+ destination: dict[int, ObjectTrackObservation],
+ observation: ObjectTrackObservation,
+ ) -> None:
+ previous = destination.get(observation.frame_index)
+ if (
+ previous is None
+ or (observation.present and not previous.present)
+ or (
+ observation.present
+ and previous.present
+ and observation.presence_probability > previous.presence_probability
+ )
+ ):
+ destination[observation.frame_index] = observation
+
+ def run_tracklet(
+ *,
+ anchor_index: int,
+ input_mask: np.ndarray | None = None,
+ input_box: tuple[float, float, float, float] | None = None,
+ propagate_reverse: bool,
+ ) -> dict[int, ObjectTrackObservation]:
+ if input_mask is not None:
+ self.processor.add_inputs_to_inference_session(
+ session,
+ frame_idx=anchor_index,
+ obj_ids=0,
+ input_masks=np.asarray(input_mask, dtype=bool),
+ clear_old_inputs=True,
+ )
+ elif input_box is not None:
+ x1, y1, x2, y2 = input_box
+ center_point = [(x1 + x2) * 0.5, (y1 + y2) * 0.5]
+ self.processor.add_inputs_to_inference_session(
+ session,
+ frame_idx=anchor_index,
+ obj_ids=0,
+ input_points=[[[center_point]]],
+ input_labels=[[[1]]],
+ input_boxes=[[[float(value) for value in input_box]]],
+ clear_old_inputs=True,
+ )
+ else:
+ raise EvaluationError("SAM2 tracklet requires a mask or box prompt")
+
+ tracklet: dict[int, ObjectTrackObservation] = {}
+ with torch.inference_mode():
+ conditioned = self.model(session, frame_idx=anchor_index)
+ store(
+ tracklet,
+ self._observation(
+ conditioned,
+ height=height,
+ width=width,
+ minimum_mask_area_ratio=minimum_mask_area_ratio,
+ ),
+ )
+ directions = [False]
+ if propagate_reverse:
+ directions.insert(0, True)
+ for reverse in directions:
+ iterator = self.model.propagate_in_video_iterator(
+ session,
+ start_frame_idx=anchor_index,
+ reverse=reverse,
+ )
+ for output in iterator:
+ store(
+ tracklet,
+ self._observation(
+ output,
+ height=height,
+ width=width,
+ minimum_mask_area_ratio=minimum_mask_area_ratio,
+ ),
+ )
+
+ connected_indices = _anchor_connected_presence_indices(
+ tracklet,
+ total_frames=len(frames_rgb),
+ anchor_index=anchor_index,
+ )
+ return {
+ index: tracklet[index] for index in connected_indices if index in tracklet and tracklet[index].present
+ }
+
+ observations = run_tracklet(
+ anchor_index=0,
+ input_mask=first_frame_mask,
+ propagate_reverse=False,
+ )
+ accepted_indices = set(observations)
+ for prompt in ordered_prompts:
+ existing = observations.get(prompt.frame_index)
+ if existing is not None and existing.present:
+ continue
+ recovered = run_tracklet(
+ anchor_index=prompt.frame_index,
+ input_box=prompt.box_xyxy,
+ propagate_reverse=True,
+ )
+ if len(recovered) < MIN_REDETECTED_TRACKLET_FRAMES:
+ continue
+ if accepted_indices.intersection(recovered):
+ continue
+ for observation in recovered.values():
+ store(observations, observation)
+ accepted_indices.update(recovered)
+
+ for frame_index in range(len(frames_rgb)):
+ observations.setdefault(
+ frame_index,
+ ObjectTrackObservation(
+ frame_index=frame_index,
+ mask=None,
+ presence_logit=None,
+ presence_probability=0.0,
+ mask_area_ratio=0.0,
+ mask_valid=False,
+ present=False,
+ ),
+ )
+ return observations
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/preparation.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/preparation.py
new file mode 100644
index 000000000..662d7b8c7
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/preparation.py
@@ -0,0 +1,72 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Read a frozen source mask and target description, without generating annotations."""
+
+import json
+from pathlib import Path
+
+import numpy as np
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+SOURCE_PATH_FIELDS = ("condition_image", "target_object_mask_first_frame")
+
+
+def source_path(sample: dict, field: str) -> str:
+ value = (sample.get("annotations") or {}).get(field)
+ if not isinstance(value, str) or not value.strip():
+ raise DataInvalidError(f"missing annotations.{field}")
+ path = Path(value).expanduser().resolve()
+ return str(path)
+
+
+def load_source(image_path: str, mask_path: str) -> tuple[np.ndarray, np.ndarray]:
+ from PIL import Image
+
+ try:
+ with Image.open(image_path) as image:
+ rgb = np.asarray(image.convert("RGB"))
+ raw = np.load(mask_path, allow_pickle=False)
+ except Exception as error:
+ raise DataInvalidError(f"cannot read object source image/mask: {error}") from error
+ if not isinstance(raw, np.ndarray) or raw.ndim != 2 or raw.shape != rgb.shape[:2]:
+ raise DataInvalidError("object_mask.npy must be a 2-D array matching the source image size")
+ # Accept boolean masks, 0/1 arrays, and the common 0/255 binary encoding.
+ if not (np.isin(raw, [0, 1]).all() or np.isin(raw, [0, 255]).all()):
+ raise DataInvalidError("object_mask.npy must be binary (bool, 0/1, or 0/255)")
+ mask = raw.astype(bool)
+ if not mask.any():
+ raise DataInvalidError("object_mask.npy cannot be empty")
+ return rgb, mask
+
+
+def prepare_for_evaluation(samples: list[dict], *, expected_revisits: int | None) -> None:
+ """Resolve case sidecars once; source contents are checked when scoring."""
+ for sample in samples:
+ if sample.get("category") not in (None, "object"):
+ continue
+ sample.pop("object_preparation_error", None)
+ try:
+ annotations = dict(sample.get("annotations") or {})
+ if not all(field in annotations for field in (*SOURCE_PATH_FIELDS, "target")):
+ case_dir = Path(sample["video"]).parent
+ prompt = json.loads((case_dir / "prompt.json").read_text(encoding="utf-8"))
+ obj = prompt["object"]
+ annotations.update(
+ condition_image=str((case_dir / prompt.get("first_frame", "first_frame.jpg")).resolve()),
+ target_object_mask_first_frame=str(
+ (case_dir / obj.get("object_mask", "object_mask.npy")).resolve()
+ ),
+ target=obj["target"],
+ )
+ if expected_revisits is not None:
+ annotations["expected_revisits"] = expected_revisits
+ else:
+ annotations.setdefault("expected_revisits", None)
+ sample["annotations"] = annotations
+ except (OSError, ValueError, KeyError, TypeError, AttributeError) as error:
+ sample["object_preparation_error"] = {
+ "status": "data_invalid",
+ "message": f"invalid Object prompt/annotations: {error}",
+ }
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/protocol.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/protocol.py
new file mode 100644
index 000000000..11b7b3720
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/protocol.py
@@ -0,0 +1,28 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Single-object input contract and fixed/automatic revisit selection."""
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+from ..revisit_protocol import validate_expected_revisits
+from .schemas import ObjectEntityProtocol
+
+
+def load_object_entity_protocol(
+ item: dict, *, total_frames: int, condition_image_path: str, target_mask_path: str
+) -> ObjectEntityProtocol:
+ annotations = item.get("annotations") or {}
+ target = annotations.get("target")
+ if not isinstance(target, str) or not target.strip():
+ raise DataInvalidError("annotations.target must be a non-empty object description")
+ revisits = validate_expected_revisits(annotations.get("expected_revisits"))
+ if total_frames < (revisits or 1) + 1:
+ raise DataInvalidError("generated video is too short for the requested object revisit split")
+ return ObjectEntityProtocol(
+ total_frames=total_frames,
+ condition_image_path=condition_image_path,
+ target_mask_path=target_mask_path,
+ target_description=target.strip(),
+ expected_revisits=revisits,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/revisit.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/revisit.py
new file mode 100644
index 000000000..f10e48e4c
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/revisit.py
@@ -0,0 +1,46 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Revisit scores and readable segment metadata for a single object."""
+
+from ..revisit_protocol import MAX_EXPECTED_REVISITS
+
+
+def revisit_score_fields(scores, coverages, *, expected_revisits):
+ if expected_revisits is None:
+ return {}
+ return {
+ f"revisit_{index + 1}_{name}": float(values[index]) if index < len(values) else 0.0
+ for index in range(MAX_EXPECTED_REVISITS)
+ for name, values in (("score", scores), ("coverage", coverages))
+ }
+
+
+def official_revisit_score(scores, *, expected_revisits):
+ count = len(scores) if expected_revisits is None else expected_revisits
+ return float(sum(scores[:count]) / count) if count else 0.0
+
+
+def revisit_metadata(phase, scores, coverages, counts, *, count_key, expected_revisits):
+ count = len(scores) if expected_revisits is None else MAX_EXPECTED_REVISITS
+ fields = {
+ "indices": (phase.revisit_indices, ()),
+ "slot": (phase.revisit_slots, ()),
+ "nominal_boundary": (phase.revisit_nominal_boundaries, None),
+ "search_window": (phase.revisit_search_windows, ()),
+ "window_source": (phase.revisit_sources, "missing"),
+ "departure_evidence": (phase.revisit_departure_evidence, False),
+ "score": (scores, 0.0),
+ "coverage": (coverages, 0.0),
+ count_key: (counts, 0),
+ }
+ output = []
+ for index in range(count):
+ row = {"revisit_id": index + 1}
+ for name, (values, default) in fields.items():
+ value = values[index] if index < len(values) else default
+ row[name] = list(value) if isinstance(value, tuple) else value
+ indices = row["indices"]
+ row["actual_window"] = [indices[0], indices[-1] + 1] if indices else []
+ output.append(row)
+ return output
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/schemas.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/schemas.py
new file mode 100644
index 000000000..346ecbd61
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/object/schemas.py
@@ -0,0 +1,95 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Typed records for Object Entity evaluation."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+import numpy as np
+
+from ..revisit_protocol import TargetPhase
+
+
+@dataclass(frozen=True)
+class ObjectEntityProtocol:
+ total_frames: int
+ condition_image_path: str
+ target_mask_path: str
+ target_description: str = ""
+ expected_revisits: int | None = None
+
+ @property
+ def required_frame_indices(self) -> tuple[int, ...]:
+ return tuple(range(self.total_frames))
+
+
+@dataclass(frozen=True)
+class ObjectTrackObservation:
+ frame_index: int
+ mask: np.ndarray | None = field(repr=False)
+ presence_logit: float | None
+ presence_probability: float
+ mask_area_ratio: float
+ mask_valid: bool
+ present: bool
+
+
+@dataclass(frozen=True)
+class ObjectRedetectionPrompt:
+ frame_index: int
+ box_xyxy: tuple[float, float, float, float]
+ detector_score: float
+ reid_similarity: float = 0.0
+ runner_up_reid_similarity: float | None = None
+ candidate_count: int = 1
+
+
+@dataclass
+class ObjectTrackAnalysis:
+ protocol: ObjectEntityProtocol
+ phase: TargetPhase
+ observations: dict[int, ObjectTrackObservation]
+ decoded_frames: dict[int, np.ndarray] = field(repr=False)
+ video_path: str = ""
+ alignment_method: str = ""
+ frame0_alignment_score: float = 0.0
+ redetection_prompts: tuple[ObjectRedetectionPrompt, ...] = ()
+ visibility_segments: tuple[tuple[int, int], ...] = ()
+ max_revisit_gap_frames: int = 0
+
+
+@dataclass(frozen=True)
+class ObjectAppearanceResult:
+ phase: TargetPhase
+ frame_scores: dict[int, float]
+ similarity: float
+ appearance_coverage: float
+ n_reference_features: int
+ n_return_features: int
+ revisit_scores: tuple[float, ...]
+ revisit_coverages: tuple[float, ...]
+ n_revisit_features: tuple[int, ...]
+
+
+@dataclass
+class ObjectAppearanceAnalysis:
+ track: ObjectTrackAnalysis
+ result: ObjectAppearanceResult
+
+
+@dataclass(frozen=True)
+class ObjectGeometryResult:
+ phase: TargetPhase
+ reference_frame_indices: tuple[int, ...]
+ frame_scores: dict[int, float]
+ similarity: float
+ geometry_coverage: float
+ area_ratio_drift: float
+ n_reference_masks: int
+ n_return_masks: int
+ revisit_scores: tuple[float, ...]
+ revisit_coverages: tuple[float, ...]
+ n_revisit_masks: tuple[int, ...]
+ revisit_area_ratio_drifts: tuple[float, ...]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/registry.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/registry.py
new file mode 100644
index 000000000..1c43dcd7b
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/registry.py
@@ -0,0 +1,29 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Explicit name-based registration of metric implementations."""
+
+import re
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.base import Metric
+
+
+class MetricRegistry:
+ def __init__(self) -> None:
+ self._metrics: dict[str, Metric] = {}
+
+ def register(self, metric: Metric) -> None:
+ # Metric names are also output-directory components.
+ if not re.fullmatch(r"[a-z][a-z0-9_-]*", metric.name):
+ raise ValueError("metric names must match [a-z][a-z0-9_-]*")
+ if metric.name in self._metrics:
+ raise ValueError(f"metric already registered: {metric.name}")
+ self._metrics[metric.name] = metric
+
+ def names(self) -> list[str]:
+ return sorted(self._metrics)
+
+ def get(self, name: str) -> Metric:
+ if name not in self._metrics:
+ raise ValueError(f"unknown metric: {name}; available: {', '.join(self.names())}")
+ return self._metrics[name]
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/revisit_protocol.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/revisit_protocol.py
new file mode 100644
index 000000000..7567fc254
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/metrics/revisit_protocol.py
@@ -0,0 +1,426 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Shared Human/Object phase allocation from target-presence runs.
+
+The protocol has ``K`` possible departure gaps::
+
+ V [D?] R1 [D?] R2 ... [D?] RK
+
+Presence components are matched to the fixed revisit boundaries. A component
+that covers several consecutive boundaries is split evenly across those
+revisits. This represents all ``2**K`` departure-insertion patterns without a
+separate never-departed fallback.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Collection, Sequence
+from dataclasses import dataclass
+from itertools import combinations
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import DataInvalidError
+
+MAX_SHORT_MISSED_FRAMES = 2
+MIN_REAPPEAR_MATCH_RUN = 3
+MAX_EXPECTED_REVISITS = 3
+REVISIT_BOUNDARY_BUFFER_FRAMES = 30
+
+
+@dataclass(frozen=True)
+class TargetPhase:
+ target_id: int
+ visible_indices: tuple[int, ...]
+ departure_indices: tuple[int, ...]
+ return_indices: tuple[int, ...]
+ status: str
+ revisit_indices: tuple[tuple[int, ...], ...] = ()
+ revisit_slots: tuple[tuple[int, int], ...] = ()
+ revisit_nominal_boundaries: tuple[int, ...] = ()
+ revisit_search_windows: tuple[tuple[int, int], ...] = ()
+ revisit_sources: tuple[str, ...] = ()
+ revisit_departure_evidence: tuple[bool, ...] = ()
+
+ @property
+ def is_scorable(self) -> bool:
+ return self.status == "ok" or (self.status == "not_departed" and bool(self.return_indices))
+
+
+def validate_expected_revisits(value: object) -> int | None:
+ if value is not None and (
+ isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= MAX_EXPECTED_REVISITS
+ ):
+ raise DataInvalidError("expected_revisits must be null (automatic) or an integer in [1, 3]")
+ return value
+
+
+def _balanced_ranges(
+ start: int,
+ end: int,
+ parts: int,
+) -> tuple[tuple[int, ...], ...]:
+ """Split ``[start, end)`` exactly once, with earlier parts one frame longer."""
+
+ if parts < 1:
+ raise ValueError("parts must be positive")
+ length = max(0, end - start)
+ base, remainder = divmod(length, parts)
+ ranges: list[tuple[int, ...]] = []
+ cursor = start
+ for part_index in range(parts):
+ size = base + int(part_index < remainder)
+ ranges.append(tuple(range(cursor, cursor + size)))
+ cursor += size
+ return tuple(ranges)
+
+
+def _close_short_internal_gaps(
+ presence: list[bool],
+ *,
+ max_gap: int,
+) -> list[bool]:
+ smoothed = list(presence)
+ index = 0
+ while index < len(smoothed):
+ if smoothed[index]:
+ index += 1
+ continue
+ start = index
+ while index < len(smoothed) and not smoothed[index]:
+ index += 1
+ if start > 0 and index < len(smoothed) and index - start <= max_gap:
+ smoothed[start:index] = [True] * (index - start)
+ return smoothed
+
+
+def _true_runs(values: Sequence[bool], *, start: int = 0) -> list[tuple[int, int]]:
+ runs: list[tuple[int, int]] = []
+ index = start
+ while index < len(values):
+ if not values[index]:
+ index += 1
+ continue
+ run_start = index
+ while index < len(values) and values[index]:
+ index += 1
+ runs.append((run_start, index - 1))
+ return runs
+
+
+def _merge_nearby_runs(
+ runs: list[tuple[int, int]],
+ *,
+ max_gap: int,
+) -> list[tuple[int, int]]:
+ merged: list[tuple[int, int]] = []
+ for start, end in runs:
+ if merged and start - merged[-1][1] - 1 <= max_gap:
+ merged[-1] = (merged[-1][0], end)
+ else:
+ merged.append((start, end))
+ return merged
+
+
+def _slot_geometry(
+ total_frames: int,
+ expected_revisits: int,
+ buffer_frames: int,
+) -> tuple[
+ tuple[tuple[int, int], ...],
+ tuple[int, ...],
+ tuple[tuple[int, int], ...],
+]:
+ slots = tuple((indices[0], indices[-1] + 1) for indices in _balanced_ranges(0, total_frames, expected_revisits))
+ boundaries = tuple(end for _, end in slots)
+ if expected_revisits == 1:
+ return slots, boundaries, ((0, total_frames),)
+
+ effective_buffer = min(
+ buffer_frames,
+ min(end - start for start, end in slots) // 2,
+ )
+ windows = tuple(
+ (
+ max(slot_start, slot_end - effective_buffer),
+ min(total_frames, slot_end + effective_buffer),
+ )
+ for slot_start, slot_end in slots
+ )
+ return slots, boundaries, windows
+
+
+def _overlap_length(
+ component: tuple[int, int],
+ window: tuple[int, int],
+) -> int:
+ start, end = component
+ window_start, window_end = window
+ return max(0, min(end + 1, window_end) - max(start, window_start))
+
+
+def _boundary_distance(component: tuple[int, int], boundary: int) -> int:
+ start, end = component
+ if start <= boundary <= end + 1:
+ return 0
+ return min(abs(start - boundary), abs(end + 1 - boundary))
+
+
+def _is_monotonic_assignment(
+ assignments: dict[int, int],
+) -> bool:
+ components = [assignments[index] for index in sorted(assignments)]
+ return components == sorted(components)
+
+
+def _assign_components(
+ components: Sequence[tuple[int, int]],
+ *,
+ boundaries: Sequence[int],
+ windows: Sequence[tuple[int, int]],
+ min_overlap_frames: int,
+) -> dict[int, int]:
+ """Map revisit boundaries to ordered visible components.
+
+ Boundary-window overlap is authoritative. Remaining post-departure
+ components are matched to the nearest free boundaries in monotonic order.
+ The initial component is never force-matched: without boundary overlap it
+ remains the full V reference.
+ """
+
+ assignments: dict[int, int] = {}
+ last_component = 0
+ for boundary_index, (boundary, window) in enumerate(zip(boundaries, windows)):
+ candidates = []
+ for component_index in range(last_component, len(components)):
+ component = components[component_index]
+ overlap = _overlap_length(component, window)
+ if overlap < min_overlap_frames:
+ continue
+ candidates.append(
+ (
+ overlap,
+ -_boundary_distance(component, boundary),
+ -component_index,
+ component_index,
+ )
+ )
+ if candidates:
+ component_index = max(candidates)[-1]
+ assignments[boundary_index] = component_index
+ last_component = component_index
+
+ used_components = set(assignments.values())
+ unmatched_post = [index for index in range(1, len(components)) if index not in used_components]
+ free_boundaries = [index for index in range(len(boundaries)) if index not in assignments]
+ pair_count = min(len(unmatched_post), len(free_boundaries))
+ if not pair_count:
+ return assignments
+
+ best: tuple | None = None
+ for component_indices in combinations(unmatched_post, pair_count):
+ for boundary_indices in combinations(free_boundaries, pair_count):
+ proposed = dict(assignments)
+ proposed.update(zip(boundary_indices, component_indices))
+ if not _is_monotonic_assignment(proposed):
+ continue
+ distances = tuple(
+ _boundary_distance(
+ components[component_index],
+ boundaries[boundary_index],
+ )
+ for boundary_index, component_index in zip(
+ boundary_indices,
+ component_indices,
+ )
+ )
+ total_length = sum(components[index][1] - components[index][0] + 1 for index in component_indices)
+ key = (
+ sum(distances),
+ max(distances),
+ -total_length,
+ boundary_indices,
+ component_indices,
+ )
+ if best is None or key < best[0]:
+ best = (key, proposed)
+ return best[1] if best is not None else assignments
+
+
+def _flatten_revisits(
+ revisits: Sequence[Sequence[int]],
+) -> tuple[int, ...]:
+ return tuple(index for revisit in revisits for index in revisit)
+
+
+def phase_from_revisit_runs(
+ *,
+ target_id: int,
+ visible_indices: tuple[int, ...],
+ total_frames: int,
+ runs: list[tuple[int, int]],
+ expected_revisits: int | None,
+ revisit_boundary_buffer_frames: int,
+) -> TargetPhase:
+ """Allocate initial visibility and post-departure runs to R1..RK."""
+
+ if not visible_indices or visible_indices[0] != 0:
+ raise ValueError("visible_indices must be a non-empty frame-0 run")
+ initial_component = (0, visible_indices[-1])
+ post_components = sorted(
+ (max(start, initial_component[1] + 1), end) for start, end in runs if end > initial_component[1]
+ )
+ post_components = [run for run in post_components if run[0] <= run[1]]
+ if expected_revisits is None:
+ # Keep observed return components intact, without fixed-time boundary
+ # allocation. The reference's no-departure/no-return scoring stays intact.
+ not_departed = initial_component[1] == total_frames - 1
+ reference = (0,) if not_departed else visible_indices
+ revisits = (
+ (tuple(range(1, total_frames)),)
+ if not_departed
+ else tuple(tuple(range(start, end + 1)) for start, end in post_components)
+ )
+ returned = _flatten_revisits(revisits)
+ used = set(reference) | set(returned)
+ return TargetPhase(
+ target_id=target_id,
+ visible_indices=reference,
+ departure_indices=tuple(index for index in range(total_frames) if index not in used),
+ return_indices=returned,
+ status="not_departed" if not_departed else "ok" if returned else "not_returned",
+ revisit_indices=revisits,
+ revisit_sources=tuple("uniform_not_departed" if not_departed else "observed_cluster" for _ in revisits),
+ revisit_departure_evidence=tuple(not not_departed for _ in revisits),
+ )
+ components = [initial_component, *post_components]
+ slots, boundaries, windows = _slot_geometry(
+ total_frames,
+ expected_revisits,
+ revisit_boundary_buffer_frames,
+ )
+
+ if expected_revisits == 1:
+ if post_components:
+ component_index = min(
+ range(1, len(components)),
+ key=lambda index: (
+ _boundary_distance(components[index], boundaries[0]),
+ -components[index][1],
+ ),
+ )
+ assignments = {0: component_index}
+ elif initial_component[1] == total_frames - 1:
+ assignments = {0: 0}
+ else:
+ assignments = {}
+ else:
+ assignments = _assign_components(
+ components,
+ boundaries=boundaries,
+ windows=windows,
+ min_overlap_frames=MIN_REAPPEAR_MATCH_RUN,
+ )
+
+ component_boundaries: dict[int, list[int]] = {}
+ for boundary_index, component_index in assignments.items():
+ component_boundaries.setdefault(component_index, []).append(boundary_index)
+
+ revisits: list[tuple[int, ...]] = [() for _ in range(expected_revisits)]
+ sources = ["missing" for _ in range(expected_revisits)]
+ departure_evidence = [False for _ in range(expected_revisits)]
+ initial_carries_revisit = 0 in component_boundaries
+ for component_index, assigned_boundaries in component_boundaries.items():
+ assigned_boundaries.sort()
+ start, end = components[component_index]
+ if component_index == 0:
+ start = max(1, start)
+ segments = _balanced_ranges(start, end + 1, len(assigned_boundaries))
+ for local_index, (boundary_index, segment) in enumerate(zip(assigned_boundaries, segments)):
+ revisits[boundary_index] = segment
+ if component_index == 0:
+ sources[boundary_index] = (
+ "uniform_not_departed"
+ if initial_component[1] == total_frames - 1
+ else "continuous_presence_until_departure"
+ )
+ else:
+ sources[boundary_index] = "observed_cluster" if local_index == 0 else "continuous_presence_split"
+ departure_evidence[boundary_index] = local_index == 0
+
+ reference_indices = (
+ (0,) if initial_carries_revisit else tuple(range(initial_component[0], initial_component[1] + 1))
+ )
+ return_indices = _flatten_revisits(revisits)
+ used = set(reference_indices) | set(return_indices)
+ departure_indices = tuple(index for index in range(total_frames) if index not in used)
+ if initial_component[1] == total_frames - 1:
+ status = "not_departed"
+ elif return_indices:
+ status = "ok"
+ else:
+ status = "not_returned"
+ return TargetPhase(
+ target_id=target_id,
+ visible_indices=reference_indices,
+ departure_indices=departure_indices,
+ return_indices=return_indices,
+ status=status,
+ revisit_indices=tuple(revisits),
+ revisit_slots=slots,
+ revisit_nominal_boundaries=boundaries,
+ revisit_search_windows=windows,
+ revisit_sources=tuple(sources),
+ revisit_departure_evidence=tuple(departure_evidence),
+ )
+
+
+def infer_target_phase(
+ *,
+ target_id: int,
+ total_frames: int,
+ matched_frame_indices: Collection[int],
+ max_short_missed_frames: int = MAX_SHORT_MISSED_FRAMES,
+ min_reappear_match_run: int = MIN_REAPPEAR_MATCH_RUN,
+ max_revisit_gap_frames: int = MAX_SHORT_MISSED_FRAMES,
+ expected_revisits: int | None = None,
+ revisit_boundary_buffer_frames: int | None = None,
+) -> TargetPhase:
+ """Infer V/D/R1..RK from one modality-specific presence sequence."""
+
+ if max_short_missed_frames < 0 or max_revisit_gap_frames < 0:
+ raise ValueError("missed-frame gap limits must be non-negative")
+ if min_reappear_match_run < 1:
+ raise ValueError("min_reappear_match_run must be positive")
+ validate_expected_revisits(expected_revisits)
+ buffer_frames = (
+ REVISIT_BOUNDARY_BUFFER_FRAMES if revisit_boundary_buffer_frames is None else revisit_boundary_buffer_frames
+ )
+ if buffer_frames < 0:
+ raise ValueError("revisit_boundary_buffer_frames must be non-negative")
+
+ matched = {int(index) for index in matched_frame_indices}
+ matched.add(0)
+ presence = [index in matched for index in range(total_frames)]
+ smoothed = _close_short_internal_gaps(
+ presence,
+ max_gap=max_short_missed_frames,
+ )
+ initial_runs = _true_runs(smoothed)
+ if not initial_runs or initial_runs[0][0] != 0:
+ raise AssertionError("the seeded first-frame presence run is missing")
+ visible_end = initial_runs[0][1]
+ visible_indices = tuple(range(visible_end + 1))
+ post_runs = [
+ (start, end)
+ for start, end in _true_runs(presence, start=visible_end + 1)
+ if end - start + 1 >= min_reappear_match_run
+ ]
+ post_runs = _merge_nearby_runs(post_runs, max_gap=max_revisit_gap_frames)
+ return phase_from_revisit_runs(
+ target_id=target_id,
+ visible_indices=visible_indices,
+ total_frames=total_frames,
+ runs=post_runs,
+ expected_revisits=expected_revisits,
+ revisit_boundary_buffer_frames=buffer_frames,
+ )
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/paths.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/paths.py
new file mode 100644
index 000000000..402de22ff
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/paths.py
@@ -0,0 +1,13 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Default location for explicitly staged WorldMemBench metric assets."""
+
+import os
+from pathlib import Path
+
+from worldfoundry.core.io.paths import checkpoint_root_path
+
+DEFAULT_WEIGHTS_DIR = Path(
+ os.environ.get("WORLDFOUNDRY_WORLDMEMBENCH_WEIGHTS_DIR", str(checkpoint_root_path("worldmembench")))
+).expanduser()
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/__init__.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/__init__.py
new file mode 100644
index 000000000..e82410ffd
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Adapters for the official pose-estimation model runtimes."""
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/da3.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/da3.py
new file mode 100644
index 000000000..1d718c9b6
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/da3.py
@@ -0,0 +1,226 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Prepare DA3 poses and scene geometry for one video.mp4 or a folder of cases."""
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+from tempfile import TemporaryDirectory, TemporaryFile
+
+
+def streaming_config(weights: Path) -> dict:
+ """Frozen reference settings; Python Sim3 is the reference runtime's solver."""
+ model = weights / "DA3NESTED-GIANT-LARGE-1.1"
+ return {
+ "Weights": {
+ "DA3": str(model / "model.safetensors"),
+ "DA3_CONFIG": str(model / "config.json"),
+ "SALAD": str(weights / "dino_salad.ckpt"),
+ },
+ "Model": {
+ "chunk_size": 120,
+ "overlap": 60,
+ "loop_chunk_size": 20,
+ "loop_enable": True,
+ "useDBoW": False,
+ "delete_temp_files": True,
+ "align_lib": "torch",
+ "align_method": "sim3",
+ "scale_compute_method": "auto",
+ "align_type": "dense",
+ "ref_view_strategy": "middle",
+ "ref_view_strategy_loop": "saddle_balanced",
+ "depth_threshold": 15.0,
+ "save_depth_conf_result": True,
+ "save_debug_info": False,
+ "Sparse_Align": {"keypoint_select": "orb", "keypoint_num": 5000},
+ # Upstream evaluates the reference YAML's scientific-notation strings.
+ "IRLS": {"delta": 0.1, "max_iters": 5, "tol": "1e-9"},
+ "Pointcloud_Save": {"sample_ratio": 0.015, "conf_threshold_coef": 0.75},
+ },
+ "Loop": {
+ "SALAD": {
+ "image_size": [336, 336],
+ "batch_size": 32,
+ "similarity_threshold": 0.85,
+ "top_k": 5,
+ "use_nms": True,
+ "nms_threshold": 25,
+ },
+ "SIM3_Optimizer": {"lang_version": "python", "max_iterations": 30, "lambda_init": "1e-6"},
+ },
+ }
+
+
+def extract_frames(video: Path, output: Path) -> int:
+ import cv2
+
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+ output.mkdir()
+ capture = cv2.VideoCapture(str(video))
+ try:
+ expected = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
+ if not capture.isOpened() or expected <= 0:
+ raise EvaluationError(f"cannot decode video: {video}")
+ count = 0
+ while True:
+ ok, frame = capture.read()
+ if not ok:
+ break
+ if not cv2.imwrite(str(output / f"frame_{count:06d}.png"), frame):
+ raise EvaluationError(f"cannot write extracted frame {count}")
+ count += 1
+ if count != expected:
+ raise EvaluationError(f"decoded {count} frames but video reports {expected}")
+ return count
+ finally:
+ capture.release()
+
+
+def convert_outputs(native: Path, output: Path, total_frames: int) -> None:
+ """Preserve full-frame poses and stride-four float16 geometry, including the final frame."""
+ import numpy as np
+
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+ c2w = np.loadtxt(native / "camera_poses.txt", dtype=np.float64).reshape(-1, 4, 4)
+ if len(c2w) != total_frames or not np.isfinite(c2w).all():
+ raise EvaluationError("DA3 camera poses must be finite and cover the entire video")
+ if not np.allclose(c2w[:, 3], [0, 0, 0, 1], atol=1e-6):
+ raise EvaluationError("DA3 camera poses have invalid homogeneous rows")
+ frame_dir = native / "results_output"
+ files = sorted(frame_dir.glob("frame_*.npz"), key=lambda path: int(path.stem.split("_")[-1]))
+ if [path.name for path in files] != [f"frame_{index}.npz" for index in range(total_frames)]:
+ raise EvaluationError("DA3 must export depth/confidence for every video frame")
+ selected = np.arange(total_frames, dtype=np.int64)[::4]
+ if selected[-1] != total_frames - 1:
+ selected = np.concatenate([selected, np.asarray([total_frames - 1], dtype=np.int64)])
+ depths, confidence, intrinsics = [], [], []
+ for index in selected:
+ with np.load(files[int(index)], allow_pickle=False) as frame:
+ depth, conf = np.asarray(frame["depth"], dtype=np.float32), np.asarray(frame["conf"], dtype=np.float32)
+ intrinsic = np.asarray(frame["intrinsics"], dtype=np.float32)
+ if depth.ndim != 2 or conf.shape != depth.shape or intrinsic.shape != (3, 3):
+ raise EvaluationError("invalid DA3 depth/confidence/intrinsics shapes")
+ if (
+ not all(np.isfinite(array).all() for array in (depth, conf, intrinsic))
+ or np.any(depth < 0)
+ or not np.any(depth > 0)
+ or np.any(conf < 0)
+ ):
+ raise EvaluationError("invalid DA3 depth/confidence values")
+ depths.append(depth.astype(np.float16))
+ confidence.append(conf.astype(np.float16))
+ intrinsics.append(intrinsic)
+ output.mkdir()
+ np.savez(
+ output / "results.npz",
+ extrinsics=np.linalg.inv(c2w)[:, :3],
+ frame_indices=np.arange(total_frames, dtype=np.int64),
+ )
+ np.savez_compressed(
+ output / "scene_geometry.npz",
+ schema_version=np.asarray([1], dtype=np.int64),
+ source_frame_count=np.asarray([total_frames], dtype=np.int64),
+ frame_indices=selected,
+ depths=np.stack(depths),
+ confidence=np.stack(confidence),
+ intrinsics=np.stack(intrinsics),
+ c2w=c2w[selected],
+ source=np.asarray(["da3_streaming_depth_conf_v1"]),
+ sample_stride=np.asarray([4], dtype=np.int64),
+ )
+
+
+def ensure_artifacts(sample, context, *, weight_metric=None, require_scene=True) -> None:
+ """Reuse complete artifacts; generate into an empty standard case/da3 directory."""
+ import yaml
+
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+
+ paths = sample["artifacts"]
+ pose = Path(paths["da3"])
+ scene = Path(paths.get("da3_scene") or pose.parent / "scene_geometry.npz")
+ if pose.is_file() and (not require_scene or scene.is_file()):
+ return
+ # Keep outputs beside the case's video link, not beside its external target.
+ video = Path(sample["video"]).absolute()
+ destination = video.parent / "da3"
+ if pose != destination / "results.npz" or scene != destination / "scene_geometry.npz":
+ raise EvaluationError("missing manifest DA3 artifacts; automatic generation uses the standard case/da3 paths")
+ if destination.exists() and any(destination.iterdir()):
+ raise EvaluationError(
+ f"incomplete DA3 artifacts in {destination}; move the existing directory aside before regenerating"
+ )
+ if not context.device.startswith("cuda"):
+ raise EvaluationError("DA3 generation requires CUDA; CPU scoring can reuse existing DA3 artifacts")
+ config = context.config.get("da3") or {}
+ if not config.get("source") or not config.get("dinov2_source"):
+ raise EvaluationError("Configure da3.source and da3.dinov2_source for DA3-Streaming, or supply DA3 artifacts")
+ source = Path(config["source"]).expanduser().resolve()
+ dinov2 = Path(config["dinov2_source"]).expanduser().resolve()
+ runner = source / "da3_streaming/da3_streaming.py"
+ if not runner.is_file() or not (dinov2 / "hubconf.py").is_file():
+ raise EvaluationError(
+ "Configured DA3-Streaming source must contain da3_streaming/da3_streaming.py; dinov2_source must contain hubconf.py"
+ )
+ weights = (
+ Path(config["weights"]).expanduser().resolve()
+ if config.get("weights")
+ else (
+ context.cache_dir / "metrics" / weight_metric if weight_metric is not None else context.cache_dir / "models"
+ )
+ )
+ settings = streaming_config(weights)
+ for path in settings["Weights"].values():
+ if not Path(path).is_file():
+ raise EvaluationError(f"missing DA3 weight: {path}; stage the official checkpoint or configure da3.weights")
+ command = [str(Path(config.get("python") or sys.executable).expanduser())]
+ index = int(context.device.split(":")[1]) if ":" in context.device else 0
+ env = dict(os.environ)
+ visible = env.get("CUDA_VISIBLE_DEVICES")
+ env["CUDA_VISIBLE_DEVICES"] = visible.split(",")[index].strip() if visible else str(index)
+ env.update(
+ HF_HUB_OFFLINE="1",
+ TRANSFORMERS_OFFLINE="1",
+ PYTHONDONTWRITEBYTECODE="1",
+ PYTHONUNBUFFERED="1",
+ DINOV2_REPO_DIR=str(dinov2),
+ )
+ env["PYTHONPATH"] = os.pathsep.join(filter(None, (str(source / "src"), env.get("PYTHONPATH"))))
+ with TemporaryDirectory(prefix=".da3-", dir=video.parent) as directory:
+ work = Path(directory)
+ count = extract_frames(video, work / "frames")
+ config_path = work / "config.yaml"
+ config_path.write_text(yaml.safe_dump(settings, sort_keys=False), encoding="utf-8")
+ # Keep verbose native output off the terminal and out of memory.
+ with TemporaryFile(dir=work) as log:
+ try:
+ subprocess.run(
+ [
+ *command,
+ str(runner),
+ "--image_dir",
+ str(work / "frames"),
+ "--config",
+ str(config_path),
+ "--output_dir",
+ str(work / "native"),
+ ],
+ cwd=runner.parent,
+ env=env,
+ check=True,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ )
+ except subprocess.CalledProcessError as error:
+ end = log.seek(0, os.SEEK_END)
+ log.seek(max(0, end - 8192))
+ tail = "\n".join(log.read().decode("utf-8", errors="replace").splitlines()[-20:])
+ raise EvaluationError(f"DA3 exited with code {error.returncode}\n{tail}".rstrip()) from error
+ convert_outputs(work / "native", work / "artifacts", count)
+ # Publish both files together. rename refuses to overwrite a non-empty directory.
+ (work / "artifacts").rename(destination)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/vipe.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/vipe.py
new file mode 100644
index 000000000..7c08c69d6
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/preparation/vipe.py
@@ -0,0 +1,109 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Prepare full-frame ViPE poses for video.mp4 cases using the default pose-only pipeline."""
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+from tempfile import TemporaryDirectory, TemporaryFile
+
+
+def vipe_command(python, source, video, output):
+ return [
+ *python,
+ str(source / "run.py"),
+ "pipeline=default",
+ "streams=raw_mp4_stream",
+ "streams.base_path=" + json.dumps(str(video), ensure_ascii=False),
+ "streams.frame_start=0",
+ "streams.frame_end=-1",
+ "streams.frame_skip=1",
+ "streams.cached=true",
+ "pipeline.post.depth_align_model=null",
+ "pipeline.output.path=" + json.dumps(str(output), ensure_ascii=False),
+ "pipeline.output.save_artifacts=true",
+ "pipeline.output.save_slam_map=false",
+ "pipeline.output.save_viz=false",
+ ]
+
+
+def convert_output(native: Path, output: Path, total_frames: int):
+ import numpy as np
+
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.metrics.camera.schemas import (
+ PoseTrajectory,
+ )
+
+ with np.load(native, allow_pickle=False) as data:
+ poses = np.asarray(data["data"], dtype=np.float64)
+ indices = np.asarray(data["inds"])
+ if poses.shape != (total_frames, 4, 4) or not np.array_equal(indices, np.arange(total_frames)):
+ raise EvaluationError("ViPE must register every frame in exact 0..F-1 order; sparse output is invalid")
+ PoseTrajectory(poses, source="vipe")
+ np.savez(output, c2w=poses, frame_indices=indices.astype(np.int64))
+
+
+def ensure_artifacts(sample, context):
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import EvaluationError
+ from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.video import VideoReader
+
+ output = Path(sample["artifacts"]["vipe"])
+ if output.is_file():
+ return
+ video = Path(sample["video"]).absolute()
+ if output != video.parent / "vipe/results.npz":
+ raise EvaluationError("missing manifest ViPE artifact; automatic generation uses case/vipe/results.npz")
+ if output.parent.exists() and any(output.parent.iterdir()):
+ raise EvaluationError(f"incomplete ViPE artifacts in {output.parent}; move them aside before regenerating")
+ if not context.device.startswith("cuda"):
+ raise EvaluationError("ViPE generation requires CUDA; CPU scoring can reuse existing poses")
+ config = context.config.get("vipe") or {}
+ if not config.get("source"):
+ raise EvaluationError("Configure vipe.source for the official ViPE default pipeline, or supply ViPE poses")
+ source = Path(config["source"]).expanduser().resolve()
+ if not (source / "run.py").is_file():
+ raise EvaluationError("Configured ViPE model runtime must contain run.py")
+ weights = (
+ Path(config.get("weights") or context.cache_dir / "metrics/camera_translation/vipe").expanduser().resolve()
+ )
+ python = [str(Path(config.get("python") or sys.executable).expanduser())]
+ count = VideoReader().probe(str(video)).total_frames
+ env = dict(os.environ)
+ index = int(context.device.split(":")[1]) if ":" in context.device else 0
+ visible = env.get("CUDA_VISIBLE_DEVICES")
+ env["CUDA_VISIBLE_DEVICES"] = visible.split(",")[index].strip() if visible else str(index)
+ env.update(
+ TORCH_HOME=str(weights / "torch"),
+ HF_HOME=str(weights / "huggingface"),
+ HF_HUB_CACHE=str(weights / "huggingface/hub"),
+ TRANSFORMERS_CACHE=str(weights / "huggingface/hub"),
+ HF_HUB_OFFLINE="1",
+ TRANSFORMERS_OFFLINE="1",
+ PYTHONDONTWRITEBYTECODE="1",
+ )
+ with TemporaryDirectory(prefix=".vipe-", dir=video.parent) as directory:
+ work = Path(directory)
+ native = work / "native"
+ with TemporaryFile(dir=work) as log:
+ try:
+ subprocess.run(
+ vipe_command(python, source, video, native),
+ cwd=source,
+ env=env,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ check=True,
+ )
+ except subprocess.CalledProcessError as error:
+ end = log.seek(0, os.SEEK_END)
+ log.seek(max(0, end - 8192))
+ tail = "\n".join(log.read().decode("utf-8", errors="replace").splitlines()[-20:])
+ raise EvaluationError(f"ViPE failed (exit {error.returncode}):\n{tail}") from error
+ converted = work / "results.npz"
+ convert_output(native / "pose" / f"{video.stem}.npz", converted, count)
+ output.parent.mkdir(exist_ok=True)
+ output.hardlink_to(converted)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/results.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/results.py
new file mode 100644
index 000000000..b0b56c9fa
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/results.py
@@ -0,0 +1,119 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Unit, item, and summary results shared by all metrics."""
+
+import json
+import shlex
+import sys
+from dataclasses import asdict, dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+
+class ResultStatus(str, Enum):
+ OK = "ok"
+ MODEL_FAILURE = "model_failure"
+ DATA_INVALID = "data_invalid"
+ EVAL_FAILED = "eval_failed"
+ NOT_APPLICABLE = "not_applicable"
+
+ @property
+ def is_scored(self) -> bool:
+ return self in {ResultStatus.OK, ResultStatus.MODEL_FAILURE}
+
+
+@dataclass
+class UnitResult:
+ unit_id: str
+ unit_type: str = "item"
+ scores: dict[str, float] = field(default_factory=dict)
+ status: ResultStatus = ResultStatus.OK
+ error: str | None = None
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class ItemResult:
+ item_id: str
+ model_id: str | None = None
+ sample_id: str | None = None
+ condition_id: str | None = None
+ category: str | None = None
+ scores: dict[str, float] = field(default_factory=dict)
+ num_units: int = 0
+ status: ResultStatus = ResultStatus.OK
+ error: str | None = None
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class SummaryResult:
+ num_items: int
+ status_counts: dict[str, int] = field(default_factory=dict)
+ scores: dict[str, float] = field(default_factory=dict)
+ metadata: dict[str, Any] = field(default_factory=dict)
+ group_by: str = "model_id"
+ results: dict[str, dict[str, Any]] = field(default_factory=dict)
+ groups: list[dict[str, Any]] = field(default_factory=list)
+
+
+class ResultWriter:
+ def __init__(self, output: str | Path) -> None:
+ self.output = Path(output)
+ self.output.mkdir(parents=True, exist_ok=False)
+
+ @staticmethod
+ def _json(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, allow_nan=False)
+
+ def write_command(self, command: list[str] | None) -> None:
+ invocation = shlex.join(command) if command is not None else "Python API call (no CLI command supplied)"
+ (self.output / "command.txt").write_text(
+ f"Working directory: {Path.cwd()}\nPython: {sys.executable}\n\n{invocation}\n",
+ encoding="utf-8",
+ )
+
+ def write_metric(self, name: str, items: list[ItemResult], summary: SummaryResult) -> None:
+ directory = self.output / "metrics" / name
+ directory.mkdir(parents=True)
+ with (directory / "cases.jsonl").open("w", encoding="utf-8") as handle:
+ for item in items:
+ handle.write(self._json(asdict(item)) + "\n")
+ (directory / "summary.json").write_text(
+ json.dumps(summary_payload(summary), ensure_ascii=False, allow_nan=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+
+def summary_payload(summary: SummaryResult) -> dict:
+ """Keep successful summaries concise, but retain non-OK coverage details."""
+
+ def compact_group(value):
+ value = dict(value)
+ metadata = dict(value.pop("metadata", {}))
+ if "n_total" in metadata:
+ value["num_cases"] = metadata.pop("n_total")
+ scored = metadata.pop("n_scored", None)
+ if scored is not None and scored != value.get("num_cases"):
+ value["num_scored"] = scored
+ counts = {status.value: metadata.pop(f"n_{status.value}", 0) for status in ResultStatus}
+ if any(count for status, count in counts.items() if status != "ok"):
+ value["status_counts"] = {status: count for status, count in counts.items() if count}
+ if metadata:
+ value["metadata"] = metadata
+ return value
+
+ payload = {"num_cases": summary.num_items}
+ if any(count for status, count in summary.status_counts.items() if status != "ok"):
+ payload["status_counts"] = {status: count for status, count in summary.status_counts.items() if count}
+ if summary.scores:
+ payload["scores"] = summary.scores
+ if summary.metadata:
+ payload["metadata"] = summary.metadata
+ if summary.results:
+ payload["results"] = {key: compact_group(value) for key, value in summary.results.items()}
+ if summary.groups:
+ payload["groups"] = [compact_group(value) for value in summary.groups]
+ return payload
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/video.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/video.py
new file mode 100644
index 000000000..f4a571a89
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/runtime/video.py
@@ -0,0 +1,93 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 ZJU-REAL/WorldMemBench contributors
+# SPDX-License-Identifier: MIT
+
+"""Original-frame-index video probing and RGB decoding."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from worldfoundry.evaluation.tasks.execution.runners.worldmembench.runtime.errors import VideoDecodeError
+
+
+@dataclass(frozen=True)
+class VideoInfo:
+ total_frames: int
+ fps: float
+ width: int
+ height: int
+
+
+@dataclass(frozen=True)
+class DecodedFrame:
+ frame_idx: int
+ rgb: Any
+
+
+def fixed_stride_indices(total_frames: int, stride: int) -> list[int]:
+ if total_frames <= 0:
+ raise ValueError("total_frames must be positive")
+ if stride <= 0:
+ raise ValueError("stride must be positive")
+ return list(range(0, total_frames, stride))
+
+
+class VideoReader:
+ def probe(self, video_path: str) -> VideoInfo:
+ import cv2
+
+ path = str(Path(video_path).expanduser().resolve())
+ capture = cv2.VideoCapture(path)
+ try:
+ total_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
+ fps = float(capture.get(cv2.CAP_PROP_FPS))
+ width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
+ height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ if not capture.isOpened() or total_frames <= 0 or width <= 0 or height <= 0:
+ raise VideoDecodeError(f"cannot probe video: {path}")
+ return VideoInfo(
+ total_frames=total_frames,
+ fps=fps,
+ width=width,
+ height=height,
+ )
+ finally:
+ capture.release()
+
+ def read_indices(
+ self,
+ video_path: str,
+ indices: list[int] | tuple[int, ...],
+ ) -> list[DecodedFrame]:
+ import cv2
+
+ path = str(Path(video_path).expanduser().resolve())
+ info = self.probe(path)
+ for index in indices:
+ if index < 0 or index >= info.total_frames:
+ raise VideoDecodeError(f"requested frame {index} outside [0, {info.total_frames - 1}] for {path}")
+ capture = cv2.VideoCapture(path)
+ frames: list[DecodedFrame] = []
+ try:
+ previous_index: int | None = None
+ for index in indices:
+ # Consecutive all-frame consumers (notably Human Entity) should
+ # decode sequentially. Seeking before every frame is orders of
+ # magnitude slower for inter-frame codecs.
+ if previous_index is None or index != previous_index + 1:
+ capture.set(cv2.CAP_PROP_POS_FRAMES, int(index))
+ ok, bgr = capture.read()
+ if not ok or bgr is None:
+ raise VideoDecodeError(f"cannot decode frame {index} from {path}")
+ frames.append(
+ DecodedFrame(
+ frame_idx=int(index),
+ rgb=cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB),
+ )
+ )
+ previous_index = int(index)
+ finally:
+ capture.release()
+ return frames
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldmembench/worldmembench_metrics.py b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/worldmembench_metrics.py
new file mode 100644
index 000000000..7f108622a
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldmembench/worldmembench_metrics.py
@@ -0,0 +1,107 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Import the official ZJU-REAL/WorldMemBench per-model metric summaries.
+
+Protocol: https://github.com/ZJU-REAL/WorldMemBench
+Scores retain upstream units and directions; there is no cross-metric average.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from pathlib import Path
+from typing import Any
+
+# Metric directory -> (official score field, higher-is-better).
+METRIC_SPECS: dict[str, tuple[str, bool]] = {
+ "imaging_quality": ("score", True),
+ "aesthetic_quality": ("score", True),
+ "human_identity": ("score", True),
+ "human_appearance": ("score", True),
+ "object_geometry": ("score", True),
+ "object_appearance": ("score", True),
+ "environment_visible_memory": ("score", True),
+ "environment_depth_fidelity": ("depth_spearman", True),
+ "environment_scene_reconstruction": ("reconstruction_score", True),
+ "dynamic_memory": ("score", True),
+ "camera_rotation": ("camera_rotation_rpe_path", False),
+ "camera_translation": ("camera_translation_rpe", False),
+}
+
+
+def load_official_results(
+ path: Path,
+ *,
+ model_id: str | None = None,
+ metrics: tuple[str, ...] = (),
+) -> tuple[str, dict[str, dict[str, Any]], list[dict[str, Any]]]:
+ """Read compact official summaries without pooling generation models.
+
+ Accept an evaluation output root, its ``metrics`` directory, or one
+ ``metrics//summary.json`` file. Official case records, when present,
+ are copied with their status and detailed scores intact.
+ """
+ unknown = set(metrics) - METRIC_SPECS.keys()
+ if unknown:
+ raise ValueError(f"Unknown WorldMemBench metrics: {', '.join(sorted(unknown))}")
+ if path.is_file():
+ paths = [path]
+ else:
+ root = path / "metrics" if (path / "metrics").is_dir() else path
+ paths = sorted(root.glob("*/summary.json"))
+ summaries: dict[str, tuple[Path, dict[str, Any]]] = {}
+ for summary_path in paths:
+ metric_id = summary_path.parent.name
+ if metric_id not in METRIC_SPECS or (metrics and metric_id not in metrics):
+ continue
+ summaries[metric_id] = (summary_path, json.loads(summary_path.read_text(encoding="utf-8")))
+ missing = set(metrics) - summaries.keys()
+ if missing:
+ raise ValueError(f"Missing requested metric summaries: {', '.join(sorted(missing))}")
+ if not summaries:
+ raise ValueError(f"No selected WorldMemBench metric summaries under {path}")
+ models = {key for _, summary in summaries.values() for key in summary.get("results", {})}
+ if model_id is None:
+ if len(models) != 1:
+ raise ValueError("Pass --model-id to select one official model group; models: " + ", ".join(sorted(models)))
+ model_id = next(iter(models))
+ if model_id not in models:
+ raise ValueError(f"Model {model_id!r} is not present in the selected summaries")
+
+ extracted: dict[str, dict[str, Any]] = {}
+ cases: list[dict[str, Any]] = []
+ for metric_id, (summary_path, summary) in summaries.items():
+ group = summary.get("results", {}).get(model_id)
+ if group is None:
+ raise ValueError(f"Model {model_id!r} is missing from {summary_path}")
+ count = int(group["num_cases"])
+ scored = int(group.get("num_scored", count))
+ statuses = group.get("status_counts", {"ok": count})
+ score_key, _ = METRIC_SPECS[metric_id]
+ raw = group.get("scores", {}).get(score_key)
+ if raw is not None:
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(raw):
+ raise ValueError(f"Non-finite or non-numeric {metric_id} score in {summary_path}")
+ raw = float(raw)
+ extracted[metric_id] = {
+ "metric_id": metric_id,
+ "raw_score": raw,
+ "normalized_score": raw,
+ "sample_count": scored,
+ "source": str(summary_path),
+ "model_id": model_id,
+ "coverage": {"num_cases": count, "num_scored": scored, "status_counts": statuses},
+ "official_scores": group.get("scores", {}),
+ "official_groups": [g for g in summary.get("groups", []) if str(g.get("model_id") or "all") == model_id],
+ }
+ case_path = summary_path.with_name("cases.jsonl")
+ if case_path.is_file():
+ for line in case_path.read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ case = json.loads(line)
+ if str(case.get("model_id") or "all") == model_id:
+ cases.append({**case, "metric_id": metric_id})
+ return model_id, extracted, cases
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/base_metrics.py b/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/base_metrics.py
index 2e7249ea3..805663914 100755
--- a/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/base_metrics.py
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/base_metrics.py
@@ -1,104 +1,23 @@
-from abc import ABC, abstractmethod
-from typing import List, Union
+"""WorldScore image metrics backed by the shared IQA implementation."""
-import pyiqa
-import pyiqa.models
-import pyiqa.models.inference_model
-import torch
-from PIL import Image
-from pyiqa.models.inference_model import InferenceModel
-from torchvision import transforms
-from torchvision.transforms import ToTensor
-from typing import Tuple
-import numpy as np
+from worldfoundry.evaluation.tasks.execution.runners.worldscore.runtime.worldscore.worldscore.common.gpu_utils import (
+ get_torch_device,
+)
+from worldfoundry.evaluation.tasks.execution.runners.worldscore.runtime.worldscore.worldscore.common.images_utils import (
+ open_image,
+)
+from worldfoundry.evaluation.tasks.metrics._shared.pyiqa import ImageQualityMetric, PyIQAMetric
-from worldfoundry.evaluation.tasks.execution.runners.worldscore.runtime.worldscore.worldscore.common.gpu_utils import get_torch_device
-from worldfoundry.evaluation.tasks.execution.runners.worldscore.runtime.worldscore.worldscore.common.images_utils import open_image
-
-class BaseMetric(ABC):
- """BaseMetric Class."""
+class BaseMetric(ImageQualityMetric):
+ """Retain WorldScore's device and local/URL image loading policy."""
def __init__(self) -> None:
- self._metric = None
- self._device = get_torch_device()
-
- def _process_image(
- self,
- rendered_images: List[Union[str, Image.Image]],
- ) -> float:
- preprocessing = transforms.Compose(
- [
- transforms.Resize((512, 512)),
- transforms.ToTensor(),
- ]
- )
-
- rendered_images_: List[torch.Tensor] = []
- for image in rendered_images:
- # Handle the rendered image input
- if isinstance(image, str):
- image = preprocessing(open_image(image))
- else:
- image = preprocessing(image)
- rendered_images_.append(image)
-
- img: torch.Tensor = torch.stack(rendered_images_).to(self._device)
-
- return img
-
- def _process_images(
- self,
- rendered_images: List[Union[str, Image.Image]],
- reference_image: Union[str, Image.Image],
- ) -> float:
- preprocessing = transforms.Compose(
- [
- transforms.Resize((512, 512)),
- transforms.ToTensor(),
- ]
- )
+ super().__init__(device=get_torch_device(), image_loader=open_image)
- # Handle the reference image input
- if isinstance(reference_image, str):
- reference_image = preprocessing(open_image(reference_image))
- rendered_images_: List[torch.Tensor] = []
- reference_images_: List[torch.Tensor] = []
- for image in rendered_images:
- # Handle the rendered image input
- if isinstance(image, str):
- image = preprocessing(open_image(image))
- else:
- image = preprocessing(image)
- rendered_images_.append(image)
+class IQAPytorchMetric(PyIQAMetric):
+ """Apply WorldScore's input policy to the selected PyIQA metric."""
- reference_images_.append(reference_image)
-
- img1: torch.Tensor = torch.stack(rendered_images_).to(self._device)
- img2: torch.Tensor = torch.stack(reference_images_).to(self._device)
-
- return img1, img2
-
- def _process_np_to_tensor(
- self,
- rendered_image: np.ndarray,
- reference_image: np.ndarray,
- ) -> Tuple[torch.Tensor, torch.Tensor]:
- img1 = ToTensor()(rendered_image).unsqueeze(0).to(self._device)
- img2 = ToTensor()(reference_image).unsqueeze(0).to(self._device)
- return img1, img2
-
- @abstractmethod
- def _compute_scores(self, *args):
- pass
-
-
-class IQAPytorchMetric(BaseMetric):
def __init__(self, metric_name: str) -> None:
- super().__init__()
- self._metric = self._create_metric(metric_name).to(self._device)
-
- def _create_metric(self, metric: str) -> InferenceModel:
- metric = pyiqa.create_metric(metric)
- return metric
+ super().__init__(metric_name, device=get_torch_device(), image_loader=open_image)
diff --git a/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/metric_impls/clip_mlp_aesthetic_metrics.py b/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/metric_impls/clip_mlp_aesthetic_metrics.py
index 82e1314da..a963a4797 100755
--- a/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/metric_impls/clip_mlp_aesthetic_metrics.py
+++ b/worldfoundry/evaluation/tasks/execution/runners/worldscore/runtime/worldscore/worldscore/benchmark/metrics/metric_impls/clip_mlp_aesthetic_metrics.py
@@ -1,110 +1,45 @@
-from typing import Union, List
-import pytorch_lightning as pl
-import torch.nn as nn
-import torch
+"""WorldScore's CLIP/LAION aesthetic metric using the shared MLP head."""
-from worldfoundry.base_models.perception_core.general_perception import openai_clip as clip
-from PIL import Image
+from collections.abc import Sequence
-from worldfoundry.evaluation.tasks.execution.runners.worldscore.runtime.worldscore.worldscore.benchmark.metrics.base_metrics import BaseMetric
-
-# if you changed the MLP architecture during training, change it also here:
-class MLP(pl.LightningModule):
- def __init__(self, input_size, xcol='emb', ycol='avg_rating'):
- super().__init__()
- self.input_size = input_size
- self.xcol = xcol
- self.ycol = ycol
- self.layers = nn.Sequential(
- nn.Linear(self.input_size, 1024),
- #nn.ReLU(),
- nn.Dropout(0.2),
- nn.Linear(1024, 128),
- #nn.ReLU(),
- nn.Dropout(0.2),
- nn.Linear(128, 64),
- #nn.ReLU(),
- nn.Dropout(0.1),
-
- nn.Linear(64, 16),
- #nn.ReLU(),
-
- nn.Linear(16, 1)
- )
-
- def forward(self, x):
- return self.layers(x)
+import torch
+from PIL import Image
- def training_step(self, batch, batch_idx):
- x = batch[self.xcol]
- y = batch[self.ycol].reshape(-1, 1)
- x_hat = self.layers(x)
- loss = F.mse_loss(x_hat, y)
- return loss
-
- def validation_step(self, batch, batch_idx):
- x = batch[self.xcol]
- y = batch[self.ycol].reshape(-1, 1)
- x_hat = self.layers(x)
- loss = F.mse_loss(x_hat, y)
- return loss
+from worldfoundry.base_models.perception_core.general_perception import openai_clip as clip
+from worldfoundry.evaluation.tasks.execution.runners.worldscore.runtime.worldscore.worldscore.benchmark.metrics.base_metrics import (
+ BaseMetric,
+)
+from worldfoundry.evaluation.tasks.metrics._shared.aesthetic import load_laion_aesthetic_mlp_head
- def configure_optimizers(self):
- optimizer = torch.optim.Adam(self.parameters(), lr=1e-3)
- return optimizer
-def normalized(a, axis=-1, order=2):
- import numpy as np # pylint: disable=import-outside-toplevel
+def normalized(a, axis: int = -1, order: int = 2):
+ """Retain the official NumPy feature normalization, including zero rows."""
+ import numpy as np
l2 = np.atleast_1d(np.linalg.norm(a, order, axis))
l2[l2 == 0] = 1
return a / np.expand_dims(l2, axis)
+
class CLIPMLPAestheticScoreMetric(BaseMetric):
- """
-
- We use the CLIP+MLP Aesthetic Score Predictor implementation:
- https://github.com/christophschuhmann/improved-aesthetic-predictor
-
- Range: [0, 10] higher the better
- """
-
+ """Score CLIP ViT-L/14 features with the improved-aesthetic-predictor MLP."""
+
def __init__(self) -> None:
super().__init__()
- model = MLP(768) # CLIP embedding dim is 768 for CLIP ViT L 14
- s = torch.load("worldscore/benchmark/metrics/checkpoints/sac+logos+ava1-l14-linearMSE.pth", map_location=torch.device('cpu')) # load the model you trained previously or the model available in this repo
+ self._model = load_laion_aesthetic_mlp_head(
+ "worldscore/benchmark/metrics/checkpoints/sac+logos+ava1-l14-linearMSE.pth"
+ ).to(self._device)
+ self._model2, self._preprocess = clip.load("ViT-L/14", device=self._device)
- model.load_state_dict(s)
-
- model.to(self._device)
- model.eval()
-
- model2, preprocess = clip.load("ViT-L/14", device=self._device) #RN50x64
-
- self._model = model
- self._model2 = model2
- self._preprocess = preprocess
-
- def _compute_scores(
- self,
- rendered_images: List[Union[str, Image.Image]],
- ) -> float:
- try:
- from torchmetrics.multimodal.clip_score import CLIPScore
- except ModuleNotFoundError as e:
- handle_module_not_found_error(e, ["worldscore"])
-
+ def _compute_scores(self, rendered_images: Sequence[str | Image.Image]) -> float:
scores = []
-
- for i, image in enumerate(rendered_images):
+ for image in rendered_images:
if isinstance(image, str):
image = Image.open(image)
- image = self._preprocess(image).unsqueeze(0).to(self._device)
+ tensor = self._preprocess(image).unsqueeze(0).to(self._device)
with torch.no_grad():
- image_features = self._model2.encode_image(image)
- im_emb_arr = normalized(image_features.cpu().detach().numpy())
- prediction = self._model(torch.from_numpy(im_emb_arr).type(torch.FloatTensor).to(self._device))
+ image_features = self._model2.encode_image(tensor)
+ features = normalized(image_features.cpu().detach().numpy())
+ prediction = self._model(torch.from_numpy(features).type(torch.FloatTensor).to(self._device))
scores.append(prediction.detach()[0].item())
-
- score = sum(scores) / len(scores)
- return score
+ return sum(scores) / len(scores)
diff --git a/worldfoundry/evaluation/tasks/metrics/_shared/pyiqa.py b/worldfoundry/evaluation/tasks/metrics/_shared/pyiqa.py
new file mode 100644
index 000000000..1edea4aba
--- /dev/null
+++ b/worldfoundry/evaluation/tasks/metrics/_shared/pyiqa.py
@@ -0,0 +1,78 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 WorldFoundry contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Image batching and PyIQA inference shared by WorldScore and 4DWorldBench.
+
+Adapted from the WorldScore and 4DWorldBench image metric bases.
+The 512-square resize is part of these benchmarks' protocol, not a default
+preprocessing policy for other IQA metrics.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Callable, Sequence
+from importlib import import_module
+from typing import TYPE_CHECKING, Any
+
+from worldfoundry.evaluation.tasks.metrics._shared.images import load_rgb_image
+
+if TYPE_CHECKING:
+ import numpy as np
+ import torch
+ from PIL import Image
+
+
+class ImageQualityMetric(ABC):
+ """Batch images using the WorldScore/4DWorldBench 512-square transform."""
+
+ def __init__(self, *, device: Any, image_loader: Callable[[str], Image.Image] = load_rgb_image) -> None:
+ self._device = device
+ self._image_loader = image_loader
+ self._metric: Any = None
+
+ def _process_image(self, rendered_images: Sequence[str | Image.Image]) -> torch.Tensor:
+ import torch
+ from torchvision import transforms
+
+ preprocessing = transforms.Compose([transforms.Resize((512, 512)), transforms.ToTensor()])
+ images = [
+ preprocessing(self._image_loader(image) if isinstance(image, str) else image) for image in rendered_images
+ ]
+ return torch.stack(images).to(self._device)
+
+ def _process_images(
+ self,
+ rendered_images: Sequence[str | Image.Image],
+ reference_image: str | Image.Image,
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ reference = self._process_image([reference_image])
+ rendered = self._process_image(rendered_images)
+ return rendered, reference.repeat(len(rendered_images), 1, 1, 1)
+
+ def _process_np_to_tensor(
+ self, rendered_image: np.ndarray, reference_image: np.ndarray
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ from torchvision.transforms import ToTensor
+
+ return (
+ ToTensor()(rendered_image).unsqueeze(0).to(self._device),
+ ToTensor()(reference_image).unsqueeze(0).to(self._device),
+ )
+
+ @abstractmethod
+ def _compute_scores(self, *args: Any) -> Any:
+ """Compute the benchmark's metric-specific score."""
+
+
+class PyIQAMetric(ImageQualityMetric):
+ """Load the explicitly selected PyIQA variant without changing its defaults."""
+
+ def __init__(
+ self, metric_name: str, *, device: Any, image_loader: Callable[[str], Image.Image] = load_rgb_image
+ ) -> None:
+ super().__init__(device=device, image_loader=image_loader)
+ self._metric = self._create_metric(metric_name).to(self._device)
+
+ def _create_metric(self, metric: str) -> Any:
+ return import_module("pyiqa").create_metric(metric)