Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@

仓库名:`ournotes-boxlens`。项目源码及训练权重采用 MIT,保留第三方许可声明;游戏资源不随项目分发,见 [资源边界](docs/ASSET_BOUNDARY.md)。输入输出见 [CONTRACT.md](docs/CONTRACT.md)。

从成员/留影列表截图识别 master ID,以及截图实际显示的等级、卡阶和成员特训次数。支持多张截图合并、重复上传去重、来源图预览和手工校对。推理使用 CPU;训练才需要 GPU。
从成员/留影列表截图识别 master ID,以及截图实际显示的等级、卡阶和成员特训次数。支持多张截图合并、重复上传去重、来源图预览和手工校对。推理只用 CPU 和 ONNX Runtime;训练才需要 GPU。

## 识别流程

1. `locator.onnx` 在整张截图上找出成员和留影卡框,包括被屏幕边缘切掉一部分的卡。
2. 每类卡各有一个卡面编码器(`member-encoder.onnx`、`snap-encoder.onnx`),把卡框内的卡面窗口编码成 128 维向量,再在本地图库里检索最相近的卡。相似度和与第二名的差距都达到阈值才接受这个 ID;否则该卡框进入 `unidentified`,附最相近的候选供人工确认。
3. 每类卡各有一个参数读取模型(`member-fields.onnx`、`snap-fields.onnx`)和一个卡阶图标模型(`member-rank.onnx`、`snap-rank.onnx`)。读取区域必须完整位于截图内,置信度达到阈值才输出值;等级还要符合 masterdata 的等级上限。

图库由使用者本地的卡面图编码而成,不属于模型权重。新卡只需更新 master 表和卡面,再重建图库,模型不用重训,见 [新卡接入](docs/NEW-CARDS.md)。

**当前版本:v0.2.0,面向列表截图。** 无法可靠读取的字段返回 `null`,无法确认身份的卡框列在 `unidentified` 里,二者都需要校对;遮挡、渐隐和重压缩仍可能造成误读,请把卡片滚动到画面中央、重新截取清晰图片后再检查。输出只表示截图观察到的卡片,不能据此认定未出现的卡片不在玩家 box 中。模型置信度和相似度是内部得分,不是经过校准的正确概率。

## 仓库结构

- `bdon_vision/`:当前识别、合成数据与本地导入界面。
- `bdon_vision/`:识别、合成数据、训练与评估模块和本地导入界面。
- `docs/`:使用说明、训练方法、资源边界、模型卡和评估说明。
- `scripts/`:资源准备、训练复现和评估工具。
- `scripts/`:资源准备、训练复现和验证工具。
- `tests/`、`schemas/`:数据完整性测试和输入输出契约。
- `reports/`:只保留训练覆盖、最终验收和性能指标;不含中间 smoke、截图或逐图调试记录,见 [报告索引](reports/README.md)。
- `reproduction/`:发布权重训练谱系所需的冻结源码和哈希清单;不是运行产物或历史实验堆栈。

**当前版本:v0.1.0,面向清晰列表截图。** 无法可靠读取的字段返回 `null`;但遮挡、渐隐和重压缩也可能造成误读,请先将卡片滚动到画面中央并重新截取清晰图片,再检查或手工校对结果。输出只表示截图观察到的卡片,不能据此认定未出现的卡片不在玩家 box 中。模型置信度是内部得分,不是经过校准的正确概率。
- `reports/`:模型选择、训练数据覆盖和最终验收的精简指标,见 [报告索引](reports/README.md)。
- `reproduction/`:初始化权重训练链所需的冻结源码和哈希清单。

## 本地启动

Expand All @@ -33,7 +41,7 @@ python -m venv .venv
python boxvision.py scan --data DATA --threads 2 --output ./result screenshot-a.jpg screenshot-b.jpg
```

输出 `observations.json`(每张截图的定位、来源指纹和字段)、`box.json`(合并结果)、带序号及来源指纹的标注图。同名输入文件不会覆盖彼此的标注图。成员与留影使用不同 ID 命名空间。
输出 `observations.json`(每张截图的卡框、身份、未识别卡框、来源指纹和字段)、`box.json`(合并结果)、带序号及来源指纹的标注图。同名输入文件不会覆盖彼此的标注图。成员与留影使用不同 ID 命名空间。

## 接入组卡推荐

Expand All @@ -47,30 +55,31 @@ python boxvision.py deck-ui --data DATA --deck-data deck-data.json --solver-bin

## 数据和模型

运行目录需要使用者自行准备 `catalog.json`、`index.npz`、卡面/canonical 图库、卡阶精灵,再将权重包中的 `encoder.onnx` 和 `fields.onnx` 放入该目录的 `models/`。公开附件只包含源码与权重,不包含游戏资源或开箱即用的图库。缺少多模式字段模型时拒绝启动,避免旧的纯等级模型把能力值当等级。`models/gallery.npz` 是可重建的缓存。
运行目录需要使用者自行准备 `catalog.json` 和卡面图,再把权重包中的 7 个 ONNX 文件和 `recognition.json` 放进该目录的 `models/`。`recognition.json` 记录每个模型的文件名、SHA-256、输入输出和接受阈值;启动时逐个校验哈希,缺文件或哈希不符都拒绝启动。`models/member-gallery.npz`、`models/snap-gallery.npz` 是可重建的图库缓存,指纹包含编码器哈希和每张参考卡面的字节。

`scripts/prepare.py` 将显式提供的 master、UI、字体与卡面目录组装为数据目录;APK 导出与完整步骤见 [资源准备](docs/RESOURCES.md)。已有完整数据包的使用者无需重新提取。`boxvision.py prepare --data DATA` 从已准备的 master 表和卡面重建索引,`--offline` 可禁止补取素材;见 [新卡更新说明](docs/NEW-CARDS.md)。
公开附件只包含源码与权重,不包含游戏资源或开箱即用的图库。`scripts/prepare.py` 将显式提供的 master、UI、字体与卡面目录组装为数据目录;APK 导出与完整步骤见 [资源准备](docs/RESOURCES.md)。已有数据目录时,`boxvision.py prepare --data DATA` 从 master 表和卡面重建目录和图库,`--offline` 可禁止补取素材。

## 校对和冲突语义

- 等级视图只读取等级;特训视图只读取特训次数;能力/百分比视图不转换为等级。
- `unidentified` 中的卡框不进入合并结果;它们可能是图库外的新卡,也可能是被切掉太多或画面太差的已知卡。
- 同一字段只有一个已观察到的值时合并该值;存在不同值时置为 `null` 并保留 `conflicts` 和全部来源,不取最大值。
- 重复截图不改变最终字段及来源证据;观察次数保留上传事实。
- 手工修正包含之前的值及修正记录。手工校对不能计入模型自动识别准确率。
- 游戏更新、UI 变更、未见过的布局或强降质图可能漏卡/漏字段。用户仍应检查总数及待校对项。

## 训练和验收

训练以合成数据为主,使用原始卡面、UI 精灵、prefab 文本位置和客户端字体。简/繁中、日、英、韩分别渲染,并覆盖不同屏幕比例、分辨率、UI 缩放、JPEG/WebP、多次压缩、模糊和滚动裁切。真实截图用于校准和回归,不能充当独立泛化准确率。
训练以合成数据为主:按列表组件的原生几何把原始卡面、UI 精灵和客户端字体渲染成整屏截图,覆盖简/繁中、日、英、韩五种语言、七种参数视图、不同屏幕比例、分辨率、UI 缩放、JPEG/WebP 多次压缩、模糊、滚动裁切、浮层遮挡和图库外卡面。真实截图只用于回归,不能充当独立泛化准确率。

见 [训练与验证说明](docs/TRAINING.md)、[字体证据](docs/evidence/language-fonts.json)、[模型卡](docs/MODEL_CARD.md)、[数据卡](docs/DATA_CARD.md)、[覆盖边界](docs/COVERAGE.md) 和 [追加训练对照](docs/EXPERIMENTS.md)。当前版本已完成清晰全卡阶矩阵 2440/2440、修正后的 840 张独立整屏测试、真实回归和 2/4 逻辑 CPU 基准,详见 [评估卡](docs/EVALUATION.md)。压力场景仍会漏卡/拒识,不用接受值精度代替端到端覆盖率。
见 [训练与验证说明](docs/TRAINING.md)、[字体证据](docs/evidence/language-fonts.json)、[模型卡](docs/MODEL_CARD.md)、[数据卡](docs/DATA_CARD.md) 和 [覆盖边界](docs/COVERAGE.md)。模型和阈值在开发集上冻结后,在两组各 2000 张、分辨率与压缩配置都不同于训练的整屏测试上评估:图库卡身份接受值零错误,召回 96.8%/97.0%;可见等级、特训次数和卡阶在图库卡上零误读,覆盖率分别约 96%、96% 和 99.6%。详见 [评估卡](docs/EVALUATION.md)。

## 检查

```powershell
python -m unittest discover -s tests -p "test_*.py"
```

涵盖跨图合并、同名不同来源、冲突保存、隐藏字段及裁切图标等数据完整性回归。
涵盖模型清单校验、跨图合并、同名不同来源、冲突保存、隐藏字段及裁切字段/图标等数据完整性回归。

可用 `python -m pip install -e ".[test]"` 安装开发入口和 Schema 校验依赖,再执行 `python scripts/validate_contract.py box result/box.json`。安装后提供 `ournotes-boxlens` 命令,参数与 `python boxvision.py` 相同。
9 changes: 5 additions & 4 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ Our Notes BoxLens's own code, documentation and released trained weights use
the MIT license in LICENSE. This does not replace third-party terms or grant
rights to game assets, data or trademarks. No game assets are distributed.

The card encoder starts from torchvision's MobileNetV3-Small ImageNet weights
and is fine-tuned as documented in MODEL_CARD.md. torchvision uses BSD-3-Clause;
its notice is retained in licenses/torchvision-BSD-3-Clause.txt. ImageNet is the
pretraining dataset, not a dataset distributed by this project.
The card encoders descend from torchvision's MobileNetV3-Small ImageNet weights
and the card-frame locator starts from torchvision's MobileNetV3-Large ImageNet
backbone; both are fine-tuned as documented in MODEL_CARD.md. torchvision uses
BSD-3-Clause; its notice is retained in licenses/torchvision-BSD-3-Clause.txt.
ImageNet is the pretraining dataset, not a dataset distributed by this project.

The optional resource preparation process uses the separately installed nnnotes
package at commit bb542af0d7e22a0255a7ee45099a51eb7fbadd12 (MIT), and UnityPy.
Expand Down
2 changes: 2 additions & 0 deletions bdon_vision/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
"""CPU-only BDON member / Snap screenshot importer."""

__version__ = '0.2.0'
68 changes: 25 additions & 43 deletions bdon_vision/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from functools import lru_cache
from pathlib import Path

import cv2
import numpy as np
from PIL import Image, ImageOps

from . import __version__

USER_AGENT = f'ournotes-boxlens/{__version__}'


def read(path):
return json.loads(Path(path).read_text(encoding='utf-8'))
Expand Down Expand Up @@ -44,13 +46,28 @@ def native_module(data):
old_line="text_values[ref('_statusValueText')]=str({'total':sum(state.power),'performance':state.power[0],'technic':state.power[1],'visual':state.power[2]}.get(state.param,''))"
new_line=old_line+"\n if variant in ('snap','formation_snap') and state.param in ('performance','technic','visual'):\n text_values[ref('_statusValueText')]=format({'performance':state.power[0],'technic':state.power[1],'visual':state.power[2]}[state.param]/100.,'.2f')+'%'"
if new_line not in code:code=code.replace(old_line,new_line)
# The list-row frame gradient catalogs carry key 20 (BD rarity) next to 2/3/4/10.
code=code.replace("if self.rarity not in (2,3,4,10): raise ValueError('Unknown rarity')",
"if self.rarity not in (2,3,4,10,20): raise ValueError('Unknown rarity')")
if code!=original:
temporary=source.with_name(source.name+f'.{os.getpid()}.tmp')
temporary.write_text(code,encoding='utf-8');os.replace(temporary,source)
spec = importlib.util.spec_from_file_location('bdon_native_renderer', source)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
crop=module.crop_sprites
def crop_sprites():
# Reuse the cropped sprite set when it matches the sprite table, so that
# many renderer processes can start without rewriting shared files.
index=module.UI/'sprite-index.json'
if index.exists():
sprites=read(index)
if sprites.keys()==read(module.UI/'sprites.json').keys() and all(
(module.UI/s.get('file','')).is_file() for s in sprites.values()):
return sprites
return crop()
module.crop_sprites=crop_sprites
from .font import NativeFont
module.GameText = lambda: NativeFont(data)
base=module.CardRenderer
Expand Down Expand Up @@ -103,7 +120,7 @@ def fetch(row):
url = f'https://assets.bdon.moe/zh-Hans/{directory}/{asset}/{stem}/{stem}.webp'
if not f.exists():
if not allow_download:raise FileNotFoundError(f'Missing local artwork: {f.name}')
request = urllib.request.Request(url, headers={'Referer': 'https://bdon.moe/', 'User-Agent': 'BDON-box-vision/0.1'})
request = urllib.request.Request(url, headers={'Referer': 'https://bdon.moe/', 'User-Agent': USER_AGENT})
with urllib.request.urlopen(request, timeout=45) as response:
body = response.read()
f.write_bytes(body)
Expand All @@ -116,7 +133,7 @@ def fetch(row):
square_url = f'https://assets.bdon.moe/zh-Hans/MemberCard/{asset}/member_thumbnail/square.webp'
if not square.exists():
if not allow_download:raise FileNotFoundError(f'Missing local artwork: {square.name}')
req = urllib.request.Request(square_url, headers={'Referer':'https://bdon.moe/', 'User-Agent':'BDON-box-vision/0.1'})
req = urllib.request.Request(square_url, headers={'Referer':'https://bdon.moe/', 'User-Agent':USER_AGENT})
with urllib.request.urlopen(req, timeout=45) as response:
square.write_bytes(response.read())
with Image.open(square) as im:
Expand Down Expand Up @@ -145,42 +162,7 @@ def fetch(row):
print(json.dumps({'catalog': len(completed), 'unavailable': failures}), flush=True)
if not completed:
raise RuntimeError('No artwork available')
build_index(data)
if (data/'models/encoder.onnx').exists():
from .inference import Gallery
Gallery(data,read(data/'catalog.json')['cards'])
print(json.dumps({'embedding_gallery':'updated'}),flush=True)


def build_index(data):
data = Path(data)
cv2.setNumThreads(2)
sift = cv2.SIFT_create(nfeatures=260, contrastThreshold=.018, edgeThreshold=12)
descriptors, points, owners = [], [], []
cards = read(data / 'catalog.json')['cards']
for i, card in enumerate(cards):
im = cv2.imread(str(data / card.get('match_file', card['file'])))
# Match at list-thumbnail resolution; original coordinates are retained.
h, w = im.shape[:2]
scales = [min(1., 220 / w)]
for scale in scales:
gray = cv2.cvtColor(cv2.resize(im, None, fx=scale, fy=scale), cv2.COLOR_BGR2GRAY)
mask = np.full(gray.shape, 255, np.uint8)
mh, mw = mask.shape
mask[int(mh*.82):, :int(mw*.4)] = 0
mask[int(mh*.72):, int(mw*.76):] = 0
mask[:int(mh*.18), :int(mw*.15)] = 0
kp, desc = sift.detectAndCompute(gray, mask)
if desc is None:
continue
# RootSIFT improves matching under screenshot compression.
desc = np.sqrt(desc / (desc.sum(axis=1, keepdims=True) + 1e-8))
descriptors.append(desc)
points.extend([(k.pt[0] / scale, k.pt[1] / scale) for k in kp])
owners.extend([i] * len(kp))
card['width'], card['height'] = w, h
np.savez_compressed(data / 'index.npz', descriptors=np.vstack(descriptors),
points=np.array(points, np.float32), owners=np.array(owners, np.int32))
write(data / 'catalog.json', {'schema': 'bdon-box-catalog/1', 'cards': cards,
'unavailable': read(data / 'catalog.json').get('unavailable', [])})
print(json.dumps({'index_descriptors': len(points)}), flush=True)
if (data/'models/recognition.json').exists():
from .engine import Engine
engine=Engine(data)
print(json.dumps({'galleries':{kind:len(g.indices) for kind,g in engine.galleries.items()}}),flush=True)
6 changes: 3 additions & 3 deletions bdon_vision/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,18 @@ def benchmark(data,images,output,cpus=2,repeats=3):
cpu_start=time.process_time()
result=engine.scan(image,path.name)
records.append({'file':path.name,'repeat':repeat,'pixels':[image.shape[1],image.shape[0]],
'cards':len(result['cards']),'elapsed_ms':result['elapsed_ms'],
'cards':len(result['cards']),'unidentified':len(result['unidentified']),'elapsed_ms':result['elapsed_ms'],
'process_cpu_ms':round((time.process_time()-cpu_start)*1000,2)})
times=[r['elapsed_ms'] for r in records]
report={'logical_cpu_affinity':affinity,'threads':cpus,'platform':platform.platform(),
'processor':platform.processor(),'host_logical_cpus':os.cpu_count(),
'model_sha256':{n:hashlib.sha256((Path(data)/'models'/n).read_bytes()).hexdigest() for n in ['encoder.onnx','fields.onnx']},
'model_manifest_sha256':hashlib.sha256((Path(data)/'models/recognition.json').read_bytes()).hexdigest(),
'startup_ms':round(startup,2),'startup_memory':startup_memory,'final_memory':memory_usage(),
'input_sha256':{p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in images},
'warmup':'one pass of all images','repeats':repeats,
'median_ms':float(np.median(times)),'p95_ms':float(np.percentile(times,95)),
'min_ms':min(times),'max_ms':max(times),'measurements':records,
'scope':'Same process constrained to listed logical CPUs. Scan includes localization and field reading; excludes file decoding and HTTP. Other host workloads are not controlled.'}
'scope':'Same process constrained to listed logical CPUs. Scan includes localization, identity retrieval and field and rank reading; excludes file decoding and HTTP. Other host workloads are not controlled.'}
write(output,report);print({k:v for k,v in report.items() if k!='measurements'})


Expand Down
3 changes: 2 additions & 1 deletion bdon_vision/deck/adapter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Strict evidence gate in front of the EXISTING Rust Roster, no scoring model."""
from copy import deepcopy
from .. import __version__
from .common import read, digest, rows, load_deck

FIELDS = {'member': {'level': 'level', 'awake': 'awake_count', 'rank': 'card_rank'},
Expand All @@ -23,7 +24,7 @@ def inventory(box, deck_path, *, mock=False, region=None, fixture_manifest=None,
return {'schema': 'ournotes.player-inventory/1', 'source': 'boxlens', 'mock': mock,
'region': region, 'deckDataSha256': deck_sha,
'masterVersion': deck['provenance']['master']['version'],
'boxlensVersion': '0.1.0', 'boxlensCommit':'2ea25d8e2fdcbd3d1514b101a13b34d3e70d2ff1',
'boxlensVersion': __version__,
'modelScope':'Offline native model 1.0.1-25; JP client 1.0.4 equivalence not inferred', 'box': deepcopy(box),
'recognitionProvenance': box.get('recognition_provenance', []),
'recognitionDataset':deepcopy(recognition_dataset),'fixtureManifest': fixture_manifest, 'coverage': 'observed_only'}
Expand Down
Loading
Loading