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 {}