Spaces:
Running
Running
File size: 7,007 Bytes
edae745 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """批次内容指纹 — 识别"换个包名重传同一份数据"。
采集端偶尔会把已经上传过的录制再推一遍,但**包名变了**(例如重装、
重开 App 后本地去重标记丢失,或包名规则从序号变成时间戳)。此时
``session.py`` 的 ``incoming_name in existing_ids`` 判不出重复,同一份
数据就会被当成新批次追加,审核页出现两条一模一样的数据,导出也会
重复计一份(实测:UMIGripper_AI 的 4 组批次视频字节级相同、
parquet 除 ``episode_index`` 外逐值相同)。
本模块给出一个与**包名无关**的内容身份:
- 每个视频文件的 md5(按 source_key 排序)
- canonical data parquet 的内容哈希(排除 ``episode_index`` ——
同一次录制被追加成不同 episode 时,只有这一列会变)
合成为一个短字符串 key,存到 ``state/batch_fingerprints.json``。
上传时先算新批次的 key,再按项目查表:命中且目标 episode 仍然存在,
就判定为重复上传。
**失败一律放行**:算不出指纹、索引读不到、目标 episode 已被删除 ——
任一情况都按"新批次"正常入库(即本模块不存在时的行为),绝不因为
指纹机制本身的问题丢数据。
"""
from __future__ import annotations
import hashlib
import json
import threading
from pathlib import Path
from typing import Any
from app.localstore import STATE_ROOT
INDEX_PATH = STATE_ROOT / "batch_fingerprints.json"
_LOCK = threading.Lock()
# 视频按块读;读取量固定,不随文件大小膨胀。
_CHUNK = 1 << 20
def _file_md5(path: Path) -> str | None:
digest = hashlib.md5()
try:
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(_CHUNK), b""):
digest.update(block)
except OSError:
return None
return digest.hexdigest()
def _parquet_content_hash(path: Path) -> str | None:
"""canonical data parquet 的内容哈希,排除 ``episode_index``。
``episode_index`` 是同一次录制被追加成不同 episode 时唯一会变的列,
必须排除,否则重复上传算不出相同的 key。用 Arrow IPC 序列化取字节:
对嵌套 list 列(手套 250x250x3 力矩阵、手部关键点)比对 pandas 的
hash 更稳,且与行序无关的假设更少 —— 这里行序本身就是内容的一部分。
"""
try:
import pyarrow as pa
import pyarrow.parquet as pq
except ImportError:
return None
try:
table = pq.read_table(path)
if "episode_index" in table.column_names:
table = table.drop_columns(["episode_index"])
sink = pa.BufferOutputStream()
with pa.ipc.new_stream(sink, table.schema) as writer:
writer.write_table(table)
return hashlib.md5(sink.getvalue().to_pybytes()).hexdigest()
except Exception:
return None
def compute_fingerprint(root: Path) -> str | None:
"""Staging 目录(已 normalize)的内容指纹;算不出返回 None。"""
root = Path(root)
parts: list[str] = []
videos_root = root / "videos"
if videos_root.is_dir():
from app.lerobot_v21 import iter_video_streams
entries: list[tuple[str, str]] = []
for source, path in iter_video_streams(videos_root):
digest = _file_md5(Path(path))
if digest is None:
return None
entries.append((str(source), digest))
if not entries:
return None
parts.append("|".join(f"v:{s}={d}" for s, d in sorted(entries)))
data_files = sorted(
path for path in (root / "data").rglob("*.parquet")
if path.is_file() and "/meta/" not in path.as_posix().casefold()
)
if not data_files:
return None
digest = _parquet_content_hash(data_files[0])
if digest is None:
return None
parts.append(f"d:{digest}")
return ";".join(parts)
def _read_index() -> dict[str, dict[str, str]]:
try:
value = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return value if isinstance(value, dict) else {}
def _write_index(index: dict) -> None:
try:
INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = INDEX_PATH.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(INDEX_PATH)
except OSError:
pass
def lookup_duplicate(project_folder: str, fingerprint: str | None,
live_ids: set[str]) -> str | None:
"""返回与本次上传内容相同的、且**仍然存在**的 episode_id。
``live_ids`` 来自当前项目数据集的实际扫描结果:索引里指向已删除
episode 的陈旧条目在这里被自然过滤掉,因此"删除后重新上传同一条
录制"仍会正常入库,不会被误判成重复。
"""
if not fingerprint:
return None
with _LOCK:
index = _read_index()
entries = index.get(str(project_folder))
if not isinstance(entries, dict):
return None
episode_id = entries.get(str(fingerprint))
if not episode_id:
return None
return str(episode_id) if str(episode_id) in live_ids else None
def record_fingerprint(project_folder: str, fingerprint: str | None,
episode_id: str) -> None:
"""记录已提交批次的内容指纹(失败静默:只是失去一次去重机会)。"""
if not fingerprint:
return
with _LOCK:
index = _read_index()
if not isinstance(index, dict):
index = {}
entries = index.get(str(project_folder))
if not isinstance(entries, dict):
entries = {}
entries[str(fingerprint)] = str(episode_id)
# 同一 episode 换内容(同名重传)时清掉它名下的旧指纹,避免
# 索引里留下永远指不到实际内容的条目。
for key in [k for k, v in entries.items()
if str(v) == str(episode_id) and k != str(fingerprint)]:
entries.pop(key, None)
index[str(project_folder)] = entries
_write_index(index)
def forget_fingerprint(episode_id: str) -> None:
"""批次被删除后清除其指纹条目。"""
target = str(episode_id)
with _LOCK:
index = _read_index()
changed = False
for entries in index.values():
if not isinstance(entries, dict):
continue
for key in [k for k, v in entries.items() if str(v) == target]:
entries.pop(key, None)
changed = True
if changed:
_write_index(index)
def describe(project_folder: str) -> dict[str, Any]:
"""诊断用:该项目已记录的内容指纹。"""
with _LOCK:
index = _read_index()
entries = index.get(str(project_folder))
return entries if isinstance(entries, dict) else {}
|