diff --git a/.github/workflows/citations.yml b/.github/workflows/citations.yml new file mode 100644 index 0000000..541e7ac --- /dev/null +++ b/.github/workflows/citations.yml @@ -0,0 +1,27 @@ +name: Verify citations + +on: + pull_request: + workflow_dispatch: + schedule: + - cron: "17 4 * * 1" # weekly, catches venue/ID drift + +permissions: + contents: read + +jobs: + citations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check reference titles, venues and years + run: python3 scripts/verify_citations.py --json citation-findings.json + - if: always() + uses: actions/upload-artifact@v4 + with: + name: citation-findings + path: citation-findings.json + if-no-files-found: ignore diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..0a885b0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,41 @@ +name: Deploy site + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements-docs.txt + - name: Build + run: python3 scripts/build_site.py --strict + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 18100cb..36b9bd4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ __pycache__/ dist/ build/ .worktrees/ +_site/ +.mkdocs-build/ +_to_delete/ +.learnings/ +.workbuddy/ diff --git a/README.md b/README.md index 800d152..b107443 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,40 @@ _Created by Codex and Xiaodong Cun (Corresponding Author), from GVC Lab, Great Bay University_ +[![Site](https://img.shields.io/badge/read-gvclab.github.io%2FVideo--Generation--101-1f6feb)](https://gvclab.github.io/Video-Generation-101/) +[![Chapters](https://img.shields.io/badge/chapters-40%2B-informational)](docs/taxonomy.md) +[![Evidence snapshot](https://img.shields.io/badge/evidence%20snapshot-2026--08-success)](docs/timeline.md) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) + +> 📖 **在线阅读(带全文搜索):** + + 一份面向初学者、研究者、工程师与创作者的视频生成知识体系。它不以堆叠模型和榜单为目标,而是试图建立一条从**生成原理、系统能力、世界理解到真实应用与验证**的完整学习路径。 > 资料更新时间:**2026-08** --- + +
+English summary + +**Video Generation 101** is a structured knowledge base on video generation, written for learners, +researchers, engineers and creators. Rather than ranking models, it builds one continuous path from +generative principles, through system capabilities and world understanding, to real applications and +the evidence needed to believe them. + +Five layers organise the material: **generation foundations**, **system capabilities**, +**controllable creation**, **world understanding**, and an **evidence framework**. + +Every chapter carries an explicit evidence snapshot date and separates author-reported numbers from +independently reproduced ones. The prose is in Chinese; paper titles, venues and links are in English, +and reference metadata is checked in CI. + +
+ +--- + ## 🎯 项目定位 视频生成正在从“根据条件合成一段画面”,走向能够持续保持状态、接受复杂控制、理解动作后果并服务真实任务的通用视频系统。本仓库围绕五个层面组织内容: @@ -41,7 +69,7 @@ _Coding Agent 扩展研究覆盖面,研究者对结论与证据负责。_ 仓库以“原理 → 系统 → 能力 → 世界 → 应用”为主轴,并让评测与治理贯穿每一层。 -![图 001:Video Generation 101 知识全景](assets/imagegen-diagrams/001/创建一张用于中文学术教程的高质量横向信息图_主题_Video_2026-08-30T17-01-58.png) +![图 001:Video Generation 101 知识全景](assets/imagegen-diagrams/001/diagram.png) ### 内容导航 | 板块 | 核心入口 | 延伸专题 | diff --git "a/assets/imagegen-diagrams/001/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_Video_2026-08-30T17-01-58.png" b/assets/imagegen-diagrams/001/diagram.png similarity index 100% rename from "assets/imagegen-diagrams/001/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_Video_2026-08-30T17-01-58.png" rename to assets/imagegen-diagrams/001/diagram.png diff --git "a/assets/imagegen-diagrams/002/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\344\273\216\344\275\277\347\224\250\345\220\210\345\220\214_2026-08-30T17-01-57.png" b/assets/imagegen-diagrams/002/diagram.png similarity index 100% rename from "assets/imagegen-diagrams/002/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\344\273\216\344\275\277\347\224\250\345\220\210\345\220\214_2026-08-30T17-01-57.png" rename to assets/imagegen-diagrams/002/diagram.png diff --git "a/assets/imagegen-diagrams/003/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\345\210\233\346\204\217\350\247\206\351\242\221\347\232\204_2026-08-30T17-01-57.png" b/assets/imagegen-diagrams/003/diagram.png similarity index 100% rename from "assets/imagegen-diagrams/003/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\345\210\233\346\204\217\350\247\206\351\242\221\347\232\204_2026-08-30T17-01-57.png" rename to assets/imagegen-diagrams/003/diagram.png diff --git "a/assets/imagegen-diagrams/004/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\350\247\206\351\242\221\347\224\237\346\210\220\350\257\204_2026-08-30T17-01-58.png" b/assets/imagegen-diagrams/004/diagram.png similarity index 100% rename from "assets/imagegen-diagrams/004/\345\210\233\345\273\272\344\270\200\345\274\240\347\224\250\344\272\216\344\270\255\346\226\207\345\255\246\346\234\257\346\225\231\347\250\213\347\232\204\351\253\230\350\264\250\351\207\217\346\250\252\345\220\221\344\277\241\346\201\257\345\233\276_\344\270\273\351\242\230_\350\247\206\351\242\221\347\224\237\346\210\220\350\257\204_2026-08-30T17-01-58.png" rename to assets/imagegen-diagrams/004/diagram.png diff --git a/assets/js/mathjax.js b/assets/js/mathjax.js new file mode 100644 index 0000000..20d915e --- /dev/null +++ b/assets/js/mathjax.js @@ -0,0 +1,23 @@ +// Material for MkDocs swaps page content without a reload when +// navigation.instant is on, so MathJax has to be re-run per navigation via the +// document$ observable rather than once on DOMContentLoaded. +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"]], + displayMath: [["\\[", "\\]"]], + processEscapes: true, + processEnvironments: true, + }, + options: { + ignoreHtmlClass: ".*|", + processHtmlClass: "arithmatex", + }, +}; + +document$.subscribe(() => { + if (!window.MathJax || !window.MathJax.typesetPromise) return; + MathJax.startup.output.clearCache(); + MathJax.typesetClear(); + MathJax.texReset(); + MathJax.typesetPromise(); +}); diff --git a/assets/zh/video-reasoning-roadmap.png b/assets/zh/video-reasoning-roadmap.png new file mode 100644 index 0000000..0840152 Binary files /dev/null and b/assets/zh/video-reasoning-roadmap.png differ diff --git a/docs/applications.md b/docs/applications.md index 8d3b3b5..d73d3f5 100644 --- a/docs/applications.md +++ b/docs/applications.md @@ -26,7 +26,7 @@ **图 1:部署是带硬门槛的证据链。** 四条领域证据并不是四个排行榜,而是说明同一个生成模型进入不同场景时,必须换一套成功标准。创作关心可控、连续与可改;数字人增加同意、身份与音画同步;交互世界要求动作响应、状态记忆和 deadline;Physical AI 最终要看反事实、闭环成功和安全。图中没有性能数字,避免把示意值误读成 benchmark 结果。 -![图 002:从使用合同到部署与回滚](../assets/imagegen-diagrams/002/创建一张用于中文学术教程的高质量横向信息图_主题_从使用合同_2026-08-30T17-01-57.png) +![图 002:从使用合同到部署与回滚](../assets/imagegen-diagrams/002/diagram.png) 顺序化文字替代:先写用户、决策和伤害,再写允许使用的文字、图像、视频、音频或动作条件;把基础模型与编辑、音频、安全和版本工具组装成工作流;人工选择和修订后,以多个随机种子、分项指标和压力测试验收。任何硬门槛失败都停止上线。通过后仍需监测服务等级、成本、隐私、来源、事故和分布漂移,并保留回滚入口。 ## 2. 五种证据对象不能混用 @@ -83,7 +83,7 @@ NIST AI 600-1 把生成式 AI 风险管理组织为面向生命周期的 govern 专业制作的核心循环是: -![图 003:创意视频的可回滚制作循环](../assets/imagegen-diagrams/003/创建一张用于中文学术教程的高质量横向信息图_主题_创意视频的_2026-08-30T17-01-57.png) +![图 003:创意视频的可回滚制作循环](../assets/imagegen-diagrams/003/diagram.png) 最低报告项: - 每个 shot 生成多少候选、采用率和返工次数; diff --git a/docs/evaluation.md b/docs/evaluation.md index 7232c3c..415a249 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -28,7 +28,7 @@ 下面是同一逻辑的可编辑、可搜索版本: -![图 004:视频生成评测证据链](../assets/imagegen-diagrams/004/创建一张用于中文学术教程的高质量横向信息图_主题_视频生成评_2026-08-30T17-01-58.png) +![图 004:视频生成评测证据链](../assets/imagegen-diagrams/004/diagram.png) 顺序化文字替代:声明任务和失败代价;按开放生成、开放集个性化、编辑、推理或 World Model 分流;先压力测试自动指标,再用冻结人类样本校准;完成人工盲测与部署门槛;最后发布分项结果、置信区间、失败类型和质量—速度—成本 Pareto。 ### 1.1 历史范式为什么不断扩大 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..0e5ecd5 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,95 @@ +site_name: Video Generation 101 +site_description: 从生成原理、系统能力、世界理解到真实应用与验证的视频生成知识体系 +site_author: Xiaodong Cun, GVC Lab, Great Bay University +site_url: https://gvclab.github.io/Video-Generation-101/ +repo_url: https://github.com/GVCLab/Video-Generation-101 +repo_name: GVCLab/Video-Generation-101 +edit_uri: edit/main/ +copyright: MIT License · GVC Lab, Great Bay University + +# The repository is browsed on GitHub as much as on the site, so the whole repo +# is the docs root. That keeps every existing relative link (docs/x.md, +# ../../assets/y.png) working identically in both places. +docs_dir: .mkdocs-build/docs +site_dir: _site + +not_in_nav: | + sources/* + +theme: + name: material + language: zh + icon: + repo: fontawesome/brands/github + features: + - navigation.instant + - navigation.tracking + - navigation.top + - navigation.indexes + - navigation.footer + - toc.follow + - search.suggest + - search.highlight + - search.share + - content.code.copy + - content.action.edit + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-night + name: 切换到深色模式 + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/weather-sunny + name: 切换到浅色模式 + +plugins: + - search: + lang: + - zh + - en + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + toc_depth: 3 + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.arithmatex: + generic: true + - pymdownx.superfences: + custom_fences: + # The chapters write display equations as ```math fences (GitHub style). + - name: math + class: arithmatex + format: !!python/object/apply:pymdownx.arithmatex.arithmatex_fenced_format + kwds: + mode: generic + tag: pre + +extra_javascript: + - assets/js/mathjax.js + - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/GVCLab + - icon: fontawesome/solid/flask + link: https://gvclab.github.io/ diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..dd8697d --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,4 @@ +mkdocs-material==9.7.6 +pymdown-extensions>=10.21 +jieba>=0.42 # Chinese tokenisation for the search index +Pillow>=10 # scripts/optimize_assets.py diff --git a/resources/datasets.md b/resources/datasets.md index 3095ea4..2a73baa 100644 --- a/resources/datasets.md +++ b/resources/datasets.md @@ -67,7 +67,7 @@ N_{\mathrm{eff,source}} 下面给出可编辑、可搜索、对读屏器更友好的确定性版本: -![图 085:可审计的现代 Video Data Engine](assets/imagegen-diagrams/085/diagram.png) +![图 085:可审计的现代 Video Data Engine](../assets/imagegen-diagrams/085/diagram.png) 顺序化文字替代: 1. 登记来源、权利证据与 opt-out 通道;通过权利门后再做不可变摄取。 diff --git a/resources/open-models.md b/resources/open-models.md index dcfd370..a8b849c 100644 --- a/resources/open-models.md +++ b/resources/open-models.md @@ -72,7 +72,7 @@ R(m)=(P,C,W,I,T,D,E,L,H), **图 1:开放模型不是一个下载按钮。** 主链把模型身份、版本、发布物、许可证、硬件、运行证据和最终 manifest 连起来;红色出口表示证据不足时的隔离路径。上图是教学总览,下方 Mermaid 是可编辑且适合读屏器的规范版本。 -![图 086:开放视频模型的可复现发布面证据链](assets/imagegen-diagrams/086/diagram.png) +![图 086:开放视频模型的可复现发布面证据链](../assets/imagegen-diagrams/086/diagram.png) 顺序化文字替代:确认作者身份;固定代码和权重 revision;分别核对九个发布面;求许可证交集;检查显存、磁盘、CUDA 和精度;用固定输入与随机种子跑最小样例;验证输出媒体契约;人工回读条件遵循和失败模式;最后保存 manifest、hash、命令、硬件和日志。任一关键门失败都应记录并隔离。 --- @@ -138,7 +138,7 @@ R(m)=(P,C,W,I,T,D,E,L,H), ## 4. 先选研究问题,再选模型 -![图 087:开放视频模型选择树](assets/imagegen-diagrams/087/diagram.png) +![图 087:开放视频模型选择树](../assets/imagegen-diagrams/087/diagram.png) ### 4.1 按显存筛选时的正确读法 | 官方入口级别 | 可先考虑的路线 | 关键限定 | @@ -175,7 +175,7 @@ R(m)=(P,C,W,I,T,D,E,L,H), 单个“最好看”的视频只能证明一次成功,不能证明模型稳定。最低限度应使用多个 prompt 与 seed,并统一输出合同:容器、编码、宽高、FPS、帧数、音轨和时长。FFmpeg 可检查媒体规格,但不能替代语义和物理评测 [[30]](#ref-30)。 -![图 088:开放模型最小复现链](assets/imagegen-diagrams/088/diagram.png) +![图 088:开放模型最小复现链](../assets/imagegen-diagrams/088/diagram.png) --- ## 6. 训练与微调:先问“开放到哪一层” diff --git a/scripts/build_site.py b/scripts/build_site.py new file mode 100755 index 0000000..7ef6a15 --- /dev/null +++ b/scripts/build_site.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Stage the repository into a MkDocs docs_dir, then build the site. + +MkDocs refuses to use the directory holding mkdocs.yml as docs_dir, but this +repo is read on GitHub as much as on the site, so its links are all written +relative to the repo root (docs/x.md, ../../assets/y.png). Copying the tree +into .mkdocs-build/docs preserves that shape exactly, so one set of links +works in both places and nothing has to be rewritten. + +Files are hard-linked where the filesystem allows it, so staging ~240MB of +diagrams costs no extra disk and no copy time. + + python3 scripts/build_site.py # build into _site/ + python3 scripts/build_site.py --serve # live preview on :8000 +""" +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +STAGE = ROOT / ".mkdocs-build" / "docs" + +# Everything the site needs, in repo-root-relative form. +INCLUDE = ["README.md", "CONTRIBUTING.md", "CITATION.cff", "LICENSE", + "docs", "assets", "resources", "bibliography", "sources"] +SKIP_DIRS = {".git", "__pycache__", ".venv", "node_modules", "_site", + ".mkdocs-build"} + + +def stage() -> None: + if STAGE.exists(): + shutil.rmtree(STAGE) + STAGE.mkdir(parents=True) + linked = copied = 0 + for name in INCLUDE: + src = ROOT / name + if not src.exists(): + print(f" skip (missing): {name}", file=sys.stderr) + continue + if src.is_file(): + _place(src, STAGE / name) + linked += 1 + continue + for path in src.rglob("*"): + if any(part in SKIP_DIRS for part in path.parts): + continue + if path.is_dir(): + continue + dst = STAGE / path.relative_to(ROOT) + dst.parent.mkdir(parents=True, exist_ok=True) + if _place(path, dst): + linked += 1 + else: + copied += 1 + print(f"staged {linked} hard-linked + {copied} copied file(s) -> {STAGE}", + file=sys.stderr) + + +def _place(src: Path, dst: Path) -> bool: + try: + os.link(src, dst) + return True + except OSError: + shutil.copy2(src, dst) + return False + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--serve", action="store_true") + ap.add_argument("--strict", action="store_true") + args = ap.parse_args() + + stage() + cmd = ["mkdocs", "serve" if args.serve else "build", + "-f", str(ROOT / "mkdocs.yml")] + if args.strict: + cmd.append("--strict") + return subprocess.call(cmd, cwd=ROOT) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fix_asset_links.py b/scripts/fix_asset_links.py new file mode 100755 index 0000000..84a0ae4 --- /dev/null +++ b/scripts/fix_asset_links.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Normalise asset filenames and repair relative links in Markdown. + +Two problems this fixes, both of which render as broken images on GitHub: + +1. Image generators write their prompt into the filename, producing paths with + CJK characters, spaces and timestamps. Those break on case-insensitive and + non-UTF-8 filesystems and are unusable as stable URLs. +2. Chapters under docs/ referenced `assets/...` when the assets live at the + repository root, so the path only resolves from the root, not from the file + doing the referencing. + +The script is idempotent: run it after adding new diagrams. + + python3 scripts/fix_asset_links.py --dry-run + python3 scripts/fix_asset_links.py +""" +from __future__ import annotations + +import argparse +import os +import re +import urllib.parse +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ASSETS = ROOT / "assets" +LINK = re.compile(r"(!?\[[^\]\[]*\]\()([^)\s]+)(\))") +MD_ROOTS = ["docs", "resources", "sources"] +IMAGE_EXT = {".png", ".webp", ".jpg", ".jpeg", ".gif", ".svg"} + +# assets/zh/ never existed; the file lives under assets/diagrams/ +MISPLACED = {"assets/zh/video-reasoning-roadmap.png": + "assets/diagrams/video-reasoning-roadmap.png"} + + +def markdown_files() -> list[Path]: + out = [ROOT / "README.md"] + for d in MD_ROOTS: + out += sorted((ROOT / d).rglob("*.md")) + return [p for p in out if p.is_file()] + + +def normalise_names(dry: bool) -> dict[str, str]: + """Rename generator-named files to stable slugs. Returns old->new (repo-relative).""" + renames: dict[str, str] = {} + + # assets/imagegen-diagrams/NNN/ holds exactly one diagram + for d in sorted(ASSETS.glob("imagegen-diagrams/[0-9][0-9][0-9]")): + files = [f for f in d.iterdir() if f.is_file()] + if len(files) == 1 and files[0].stem != "diagram": + new = d / ("diagram" + files[0].suffix) + renames[str(files[0].relative_to(ROOT))] = str(new.relative_to(ROOT)) + if not dry: + os.rename(files[0], new) + + # assets/imagegen-diagrams/batches// holds the multi-up source sheets + for d in sorted(ASSETS.glob("imagegen-diagrams/batches/*")): + if not d.is_dir(): + continue + for i, f in enumerate(sorted(p for p in d.iterdir() if p.is_file()), 1): + new = d / f"sheet-{i:02d}{f.suffix}" + if f != new: + renames[str(f.relative_to(ROOT))] = str(new.relative_to(ROOT)) + if not dry: + os.rename(f, new) + return renames + + +def repair_links(renames: dict[str, str], dry: bool) -> tuple[int, list[str]]: + fixed = 0 + unresolved: list[str] = [] + for md in markdown_files(): + text = original = md.read_text(encoding="utf-8") + + def repl(m: re.Match) -> str: + nonlocal fixed + pre, target, post = m.groups() + if target.startswith(("http://", "https://", "mailto:", "#")): + return m.group(0) + raw = urllib.parse.unquote(target.split("#")[0]) + if not raw or (md.parent / raw).exists(): + return m.group(0) + if Path(raw).suffix.lower() not in IMAGE_EXT: + return m.group(0) + # re-anchor against the repository root, then apply known fixups + norm = raw.lstrip("./") + while norm.startswith("../"): + norm = norm[3:] + norm = MISPLACED.get(norm, norm) + norm = renames.get(norm, norm) + if not (ROOT / norm).exists(): + unresolved.append(f"{md.relative_to(ROOT)}: {target}") + return m.group(0) + fixed += 1 + return f"{pre}{os.path.relpath(ROOT / norm, md.parent)}{post}" + + text = LINK.sub(repl, text) + if text != original and not dry: + md.write_text(text, encoding="utf-8") + return fixed, unresolved + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + renames = normalise_names(args.dry_run) + print(f"{'would rename' if args.dry_run else 'renamed'} {len(renames)} asset file(s)") + fixed, unresolved = repair_links(renames, args.dry_run) + print(f"{'would repair' if args.dry_run else 'repaired'} {fixed} Markdown link(s)") + for u in unresolved: + print(f" UNRESOLVED {u}") + return 1 if unresolved else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/optimize_assets.py b/scripts/optimize_assets.py new file mode 100755 index 0000000..e0dcaef --- /dev/null +++ b/scripts/optimize_assets.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Convert diagram PNGs to WebP and repoint every Markdown reference. + +The diagrams are 1672x941 AI-rendered images. As PNG they cost ~119 MB, which +every clone pays for. WebP q=95 reproduces them at higher fidelity than a +256-colour PNG at roughly a sixth of the size, and renders natively on GitHub +and in every current browser. + + python3 scripts/optimize_assets.py --dry-run + python3 scripts/optimize_assets.py +""" +from __future__ import annotations + +import argparse +import os +import re +import urllib.parse +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parent.parent +QUALITY = 95 +LINK = re.compile(r"(!?\[[^\]\[]*\]\()([^)\s]+)(\))") +MD_ROOTS = ["docs", "resources", "sources"] + + +def markdown_files() -> list[Path]: + out = [ROOT / "README.md"] + for d in MD_ROOTS: + out += sorted((ROOT / d).rglob("*.md")) + return [p for p in out if p.is_file()] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--quality", type=int, default=QUALITY) + args = ap.parse_args() + + pngs = sorted((ROOT / "assets").rglob("*.png")) + before = after = 0 + mapping: dict[str, str] = {} + + for png in pngs: + webp = png.with_suffix(".webp") + im = Image.open(png) + im = im.convert("RGBA") if im.mode in ("RGBA", "LA", "P") else im.convert("RGB") + before += png.stat().st_size + if args.dry_run: + import io + buf = io.BytesIO(); im.save(buf, "WEBP", quality=args.quality, method=6) + after += buf.tell() + else: + im.save(webp, "WEBP", quality=args.quality, method=6) + after += webp.stat().st_size + png.unlink() + mapping[str(png.relative_to(ROOT))] = str(webp.relative_to(ROOT)) + + print(f"{len(pngs)} PNG -> WebP q{args.quality}: " + f"{before/1048576:.1f} MB -> {after/1048576:.1f} MB " + f"({after/max(before,1):.0%}, saved {(before-after)/1048576:.1f} MB)") + + if args.dry_run: + return 0 + + rewritten = 0 + for md in markdown_files(): + text = original = md.read_text(encoding="utf-8") + + def repl(m: re.Match) -> str: + nonlocal rewritten + pre, target, post = m.groups() + if target.startswith(("http://", "https://", "mailto:", "#")): + return m.group(0) + raw = urllib.parse.unquote(target.split("#")[0]) + if not raw.lower().endswith(".png"): + return m.group(0) + abs_old = (md.parent / raw).resolve() + try: + key = str(abs_old.relative_to(ROOT)) + except ValueError: + return m.group(0) + if key not in mapping: + return m.group(0) + rewritten += 1 + return f"{pre}{os.path.relpath(ROOT / mapping[key], md.parent)}{post}" + + text = LINK.sub(repl, text) + if text != original: + md.write_text(text, encoding="utf-8") + print(f"repointed {rewritten} Markdown reference(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_citations.py b/scripts/verify_citations.py new file mode 100755 index 0000000..3177d5b --- /dev/null +++ b/scripts/verify_citations.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Verify in-chapter reference entries against authoritative sources. + +The central bibliography (bibliography/registry.json) is already machine-built +from arXiv/Crossref, and CI runs lychee for dead links. Neither checks the +~2000 hand-written per-chapter reference entries, where the anchor text +(title), venue and year can be wrong even when the URL resolves fine. + +This script checks exactly that: + * arXiv links -> title claimed in anchor text vs real arXiv title + -> year claimed in the line vs arXiv v1 announcement year + * CVF / ICLR / NeurIPS / ECVA links -> venue+year encoded in the URL path + vs venue+year claimed in the line + +Usage: + python3 scripts/verify_citations.py # full run + python3 scripts/verify_citations.py --offline # URL-path checks only + python3 scripts/verify_citations.py --json out.json +""" +from __future__ import annotations + +import argparse +import difflib +import json +import re +import sys +import time +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field, asdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SCAN_DIRS = ["docs", "resources", "sources"] +SCAN_FILES = ["README.md"] + +ARXIV_API = "https://export.arxiv.org/api/query" +ARXIV_BATCH = 50 +ARXIV_SLEEP = 3.0 # arXiv asks for >=3s between programmatic requests +TITLE_FLAG = 0.72 # similarity below this is reported + +MD_LINK = re.compile(r"\[([^\]\[]+)\]\((https?://[^\s)]+)\)") +ARXIV_ABS = re.compile(r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})") +YEAR = re.compile(r"\b(19[89]\d|20[0-3]\d)\b") + +# venue + year encoded in the URL path +URL_VENUE = [ + (re.compile(r"openaccess\.thecvf\.com/content/([A-Za-z]+)(\d{4})"), None), + (re.compile(r"openaccess\.thecvf\.com/content_(\w+?)_(\d{4})"), None), + (re.compile(r"proceedings\.iclr\.cc/paper_files/paper/(\d{4})"), "ICLR"), + (re.compile(r"proceedings\.neurips\.cc/paper_files/paper/(\d{4})"), "NeurIPS"), + (re.compile(r"papers\.nips\.cc/paper_files/paper/(\d{4})"), "NeurIPS"), + (re.compile(r"ecva\.net/papers/eccv_(\d{4})"), "ECCV"), +] +VENUE_ALIASES = { + "NEURIPS": "NEURIPS", "NIPS": "NEURIPS", "NEURAL": "NEURIPS", + "CVPR": "CVPR", "ICCV": "ICCV", "WACV": "WACV", "ECCV": "ECCV", + "ICLR": "ICLR", "ICML": "ICML", "AAAI": "AAAI", "SIGGRAPH": "SIGGRAPH", + "CVPRW": "CVPR", "ICCVW": "ICCV", +} +VENUE_TOKEN = re.compile( + r"\b(CVPR|ICCV|ECCV|WACV|ICLR|ICML|NeurIPS|NIPS|AAAI|SIGGRAPH|TPAMI|IJCV|TMLR|TVCG)\b", + re.I, +) + + +@dataclass +class Finding: + severity: str + kind: str + file: str + line: int + url: str + claimed: str + actual: str + note: str = "" + + +@dataclass +class Ref: + file: str + line: int + text: str # anchor text + url: str + context: str # the whole source line + arxiv_id: str | None = None + + +def norm_title(s: str) -> str: + s = s.lower() + s = re.sub(r"[‐-―]", "-", s) + s = re.sub(r"[^a-z0-9]+", " ", s) + return " ".join(s.split()) + + +def title_score(claimed: str, actual: str) -> float: + a, b = norm_title(claimed), norm_title(actual) + if not a or not b: + return 0.0 + if a == b: + return 1.0 + # anchor text often prefixes the real title with a short model name + # ("PAB: Real-Time ..." vs "Real-Time ..."), or truncates the subtitle. + if a in b or b in a: + return 0.97 + at, bt = set(a.split()), set(b.split()) + jacc = len(at & bt) / max(1, len(at | bt)) + return max(difflib.SequenceMatcher(None, a, b).ratio(), jacc) + + +def iter_markdown() -> list[Path]: + out: list[Path] = [] + for d in SCAN_DIRS: + p = ROOT / d + if p.is_dir(): + out += sorted(p.rglob("*.md")) + for f in SCAN_FILES: + p = ROOT / f + if p.is_file(): + out.append(p) + return out + + +def collect_refs() -> list[Ref]: + refs: list[Ref] = [] + for path in iter_markdown(): + rel = str(path.relative_to(ROOT)) + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for text, url in MD_LINK.findall(line): + if text.startswith("!") or "img.shields.io" in url: + continue + m = ARXIV_ABS.search(url) + refs.append(Ref(rel, lineno, text.strip(), url, + line.strip(), m.group(1) if m else None)) + return refs + + +class ArxivUnavailable(RuntimeError): + """The arXiv API could not be reached; absence of a record proves nothing.""" + + +def arxiv_lookup(ids: list[str], verbose: bool = True) -> tuple[dict[str, dict], set[str]]: + """Return (metadata_by_id, ids_actually_queried). + + Only ids in the second element carry a meaningful negative result. Ids from + a failed batch are simply unknown and must not be reported as missing. + """ + out: dict[str, dict] = {} + queried: set[str] = set() + failed_batches = 0 + total_batches = (len(ids) + ARXIV_BATCH - 1) // ARXIV_BATCH + for i in range(0, len(ids), ARXIV_BATCH): + chunk = ids[i:i + ARXIV_BATCH] + params = urllib.parse.urlencode( + {"id_list": ",".join(chunk), "max_results": len(chunk)} + ) + if verbose: + print(f" arXiv batch {i // ARXIV_BATCH + 1}" + f"/{(len(ids) + ARXIV_BATCH - 1) // ARXIV_BATCH}" + f" ({len(chunk)} ids)", file=sys.stderr) + try: + with urllib.request.urlopen(f"{ARXIV_API}?{params}", timeout=90) as r: + root = ET.fromstring(r.read()) + except Exception as exc: # network / API failure + failed_batches += 1 + print(f" ! batch failed: {exc}", file=sys.stderr) + time.sleep(ARXIV_SLEEP) + continue + queried.update(chunk) + ns = {"a": "http://www.w3.org/2005/Atom"} + for entry in root.findall("a:entry", ns): + eid = entry.findtext("a:id", "", ns) + m = re.search(r"abs/(\d{4}\.\d{4,5})v(\d+)", eid) + if not m: + continue + out[m.group(1)] = { + "title": " ".join((entry.findtext("a:title", "", ns) or "").split()), + "published": entry.findtext("a:published", "", ns), + "authors": [a.findtext("a:name", "", ns) + for a in entry.findall("a:author", ns)], + } + time.sleep(ARXIV_SLEEP) + if total_batches and failed_batches == total_batches: + raise ArxivUnavailable( + "every arXiv batch failed -- the API is unreachable from this host. " + "Refusing to report missing records, which would all be false." + ) + if failed_batches: + print(f" ! {failed_batches}/{total_batches} batches failed; " + f"{len(ids) - len(queried)} ids left unverified", file=sys.stderr) + return out, queried + + +def check_arxiv(refs: list[Ref], meta: dict[str, dict], + queried: set[str]) -> list[Finding]: + per_line: dict[tuple[str, int], int] = {} + for r in refs: + per_line[(r.file, r.line)] = per_line.get((r.file, r.line), 0) + 1 + findings: list[Finding] = [] + for r in refs: + if not r.arxiv_id: + continue + info = meta.get(r.arxiv_id) + if info is None: + if r.arxiv_id not in queried: + continue # never successfully queried -- unknown, not absent + findings.append(Finding( + "ERROR", "arxiv-not-found", r.file, r.line, r.url, + r.text, "(arXiv API returned nothing)", + "id may be wrong, withdrawn, or not yet announced")) + continue + # title check -- only when the anchor text looks like a title + if len(r.text) > 12 and not r.text.lower().startswith(("http", "github", "官方", "见")): + score = title_score(r.text, info["title"]) + if score < TITLE_FLAG: + findings.append(Finding( + "ERROR", "title-mismatch", r.file, r.line, r.url, + r.text, info["title"], f"similarity={score:.2f}")) + # year check -- compare arXiv v1 year against years stated on the line + pub_year = info["published"][:4] + stated = set(YEAR.findall(r.context)) if per_line[(r.file, r.line)] == 1 else set() + if pub_year and stated and pub_year not in stated: + # allow a claimed venue year later than the preprint year + if all(int(y) < int(pub_year) for y in stated): + findings.append(Finding( + "WARN", "year-before-preprint", r.file, r.line, r.url, + "/".join(sorted(stated)), f"arXiv v1 {pub_year}", + "line claims a year earlier than the first arXiv version")) + return findings + + +def check_url_venue(refs: list[Ref]) -> list[Finding]: + """Venue/year encoded in the URL vs venue/year claimed on the same line. + + Only applied to lines carrying exactly one reference link. Table rows list + several papers from different venues side by side, so a line-level claim + cannot be attributed to a specific link there. + """ + per_line: dict[tuple[str, int], int] = {} + for r in refs: + per_line[(r.file, r.line)] = per_line.get((r.file, r.line), 0) + 1 + findings: list[Finding] = [] + for r in refs: + if per_line[(r.file, r.line)] != 1: + continue + got_venue = got_year = None + for pat, fixed in URL_VENUE: + m = pat.search(r.url) + if not m: + continue + if fixed: + got_venue, got_year = fixed, m.group(1) + else: + got_venue, got_year = m.group(1), m.group(2) + break + if not got_venue: + continue + gv = VENUE_ALIASES.get(got_venue.upper(), got_venue.upper()) + # what does the surrounding line claim? + claimed_venues = {VENUE_ALIASES.get(v.upper(), v.upper()) + for v in VENUE_TOKEN.findall(r.context)} + claimed_years = set(YEAR.findall(r.context)) + if claimed_venues and gv not in claimed_venues: + findings.append(Finding( + "ERROR", "venue-mismatch", r.file, r.line, r.url, + "/".join(sorted(claimed_venues)), f"{gv} (from URL)")) + if claimed_years and got_year not in claimed_years: + findings.append(Finding( + "ERROR", "venue-year-mismatch", r.file, r.line, r.url, + "/".join(sorted(claimed_years)), f"{got_year} (from URL)")) + return findings + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--offline", action="store_true", + help="skip arXiv API, run URL-path checks only") + ap.add_argument("--json", type=Path, help="write findings as JSON") + ap.add_argument("--fail-on", default="ERROR", choices=["ERROR", "WARN", "NONE"]) + args = ap.parse_args() + + refs = collect_refs() + arxiv_ids = sorted({r.arxiv_id for r in refs if r.arxiv_id}) + print(f"scanned {len(set(r.file for r in refs))} files, " + f"{len(refs)} links, {len(arxiv_ids)} unique arXiv ids", + file=sys.stderr) + + findings = check_url_venue(refs) + unverified = 0 + if not args.offline: + try: + meta, queried = arxiv_lookup(arxiv_ids) + except ArxivUnavailable as exc: + print(f"\nFATAL: {exc}", file=sys.stderr) + print("Re-run with --offline for URL-path checks only.", file=sys.stderr) + return 2 + findings += check_arxiv(refs, meta, queried) + unverified = len(arxiv_ids) - len(queried) + + order = {"ERROR": 0, "WARN": 1} + findings.sort(key=lambda f: (order.get(f.severity, 2), f.file, f.line)) + + for f in findings: + print(f"[{f.severity}] {f.kind} {f.file}:{f.line}") + print(f" claimed: {f.claimed}") + print(f" actual : {f.actual}") + if f.note: + print(f" note : {f.note}") + print(f" url : {f.url}") + + errs = sum(1 for f in findings if f.severity == "ERROR") + warns = sum(1 for f in findings if f.severity == "WARN") + print(f"\n{errs} error(s), {warns} warning(s)" + + (f", {unverified} arXiv id(s) unverified" if unverified else ""), + file=sys.stderr) + + if args.json: + args.json.write_text(json.dumps([asdict(f) for f in findings], + ensure_ascii=False, indent=2), + encoding="utf-8") + + if args.fail_on == "ERROR": + return 1 if errs else 0 + if args.fail_on == "WARN": + return 1 if (errs or warns) else 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sources/research_20260829_evaluation.md b/sources/research_20260829_evaluation.md index 7e43cb1..c4297a9 100644 --- a/sources/research_20260829_evaluation.md +++ b/sources/research_20260829_evaluation.md @@ -89,7 +89,7 @@ For each promoted source, the audit checked the title, venue/status, current ver The review workflow is shown below. This is a trace diagram, not a numerical PRISMA flow because a complete candidate denominator was not captured. -![图 089:Repository claims and named benchmark families到Benchmark registry and evaluation protocol的流程](assets/imagegen-diagrams/089/diagram.png) +![图 089:Repository claims and named benchmark families到Benchmark registry and evaluation protocol的流程](../assets/imagegen-diagrams/089/diagram.png) ## Evidence levels Evidence level describes the source and the maximum claim it can safely support. It is not a score for paper quality. @@ -431,7 +431,7 @@ The interpretation boundary is non-negotiable: absence of C2PA does not prove me The protocol below prevents a metric from being promoted beyond the task it measures. -![图 090:Write claim card: task, conditions, use and failure cost到Per-dimension results, failures and Pareto frontier的流程](assets/imagegen-diagrams/090/diagram.png) +![图 090:Write claim card: task, conditions, use and failure cost到Per-dimension results, failures and Pareto frontier的流程](../assets/imagegen-diagrams/090/diagram.png) | Task | Evaluation unit | Primary evidence | Claim that remains unsupported | |---|---|---|---| | Open generation | Prompt by seed | Quality, distribution/coverage, compositional adherence, time, task-specific physics and human preference | Action causality or planning value | diff --git a/sources/research_20260829_generative_models.md b/sources/research_20260829_generative_models.md index 5a446bc..cd5519c 100644 --- a/sources/research_20260829_generative_models.md +++ b/sources/research_20260829_generative_models.md @@ -378,7 +378,7 @@ Few-step 只降低每帧/chunk 的 NFE,causal factorization 只限制信息方 图名建议为“视频生成系统不是单标签:五个正交选择汇合为一个可评测系统”。图不能画成 VAE → AR → Diffusion → Streaming 的线性进化,因为五轴可以自由组合。画面应先让五个独立选择汇入 `Configured generator`,再从系统声明反向指向相应证据。 -![图 091:R · Representation\npixel / continuous latent / discrete token到StreamDiffusionV2\nmodel + cache/scheduler/pipeline + SLO的流程](assets/imagegen-diagrams/091/diagram.png) +![图 091:R · Representation\npixel / continuous latent / discrete token到StreamDiffusionV2\nmodel + cache/scheduler/pipeline + SLO的流程](../assets/imagegen-diagrams/091/diagram.png) ### 10.2 节点与视觉规范 | 节点 ID | 形状 | 色彩建议 | 文本上限 | 进入/离开边 | diff --git a/sources/research_20260829_physical_consistency.md b/sources/research_20260829_physical_consistency.md index 6a79972..56829ec 100644 --- a/sources/research_20260829_physical_consistency.md +++ b/sources/research_20260829_physical_consistency.md @@ -116,7 +116,7 @@ E_k= ## 4. 证据阶梯 -![图 092:L0 · 视觉真实感单帧外观与参考相似到禁止压成一个不透明总分的流程](assets/imagegen-diagrams/092/diagram.png) +![图 092:L0 · 视觉真实感单帧外观与参考相似到禁止压成一个不透明总分的流程](../assets/imagegen-diagrams/092/diagram.png) 使用规则: - claim 不得高于实际通过的最高证据层;L2 高分不能写成 L3,L3 也不能自动写成 L6。 @@ -242,7 +242,7 @@ E_k= ## 7. 训练、后训练与验证路线 -![图 093:成组数据真实标定 + 模拟干预 + action logs到按证据层分别报告的流程](assets/imagegen-diagrams/093/diagram.png) +![图 093:成组数据真实标定 + 模拟干预 + action logs到按证据层分别报告的流程](../assets/imagegen-diagrams/093/diagram.png) 推荐的工程顺序: 1. **先定义 claim。** 如果目标只是减少明显穿透,就不应把工作描述成“学习真实世界定律”;如果目标是机器人 planning,就必须预先定义决策指标。