File size: 13,558 Bytes
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
"""深度数据读取(v1.1.2 12-bit 灰度 MP4 + 旧 MKV/png16 回退)。

新格式 depth/<slot>/<slot>.mp4:单视频轨 hevc (Rext) gray12le ——
12-bit 对数深度码(core.depth_codec 量化域),ffmpeg CLI 解
gray12le 后反量化回 uint16 毫米。
旧格式回退:
  depth/<slot>/<slot>.mkv   v1.0.14 双流(流0 热力图 h264 + 流1
                            FFV1 gray16le 无损 uint16 毫米);
                            迁移合成件 = 单流 FFV1(流0)。
  depth/<slot>/*.png        v1.0.13 及以前 PNG16 序列。

cv2 的 CAP_PROP_STREAM_INDEX 对 ffmpeg 后端不可靠(实测不生效),
数据轨统一走 ffmpeg CLI 解 rawvideo。
"""

from __future__ import annotations
import glob
import os
import re
import subprocess
from typing import Iterator, Optional

import cv2
import numpy as np

from core.depth_codec import codes_to_heatmap_bgr, dequantize_depth
from core.encoder_probe import list_working_ffmpegs
from core.helpers import (egodata_depth_path, egodata_depth_video_path,
                          episode_video_files)


def _get_ffmpeg() -> Optional[str]:
    bins = list_working_ffmpegs()
    return bins[0] if bins else None


def has_depth_mkv(episode_dir: str, depth_name: str) -> bool:
    """该槽是否有视频深度(12-bit 灰 MP4 / 双流 MKV 任一)。"""
    return os.path.isfile(egodata_depth_video_path(episode_dir, depth_name))


def iter_depth_frames(episode_dir: str, depth_name: str) -> Iterator[np.ndarray]:
    """顺序产出 uint16 毫米深度帧((H, W) numpy 数组)。

    新格式:12-bit 灰度 MP4(gray12le 解码 + 反量化);
    旧格式:双流/单流 FFV1 MKV(uint16 毫米直出);
    更旧:depth/<slot>/000001.png 序列(v1.0.13 及以前)。
    """
    path = egodata_depth_video_path(episode_dir, depth_name)
    if os.path.isfile(path):
        if path.lower().endswith(".mp4"):
            yield from _iter_mp4_depth_frames_path(path)
        else:
            yield from _iter_mkv_depth_frames_path(path)
        return
    yield from _iter_png16_frames(episode_dir, depth_name)


def _ffv1_stream_index(ffmpeg: str, mkv_path: str) -> Optional[int]:
    """探测 MKV 里 FFV1 无损深度轨的流号(stderr 解析,无则 None)。

    v1.0.14 双流 = 流0 热力图 h264 + 流1 FFV1;迁移合成件 = 单流 FFV1
    (流0)。解码前探测一次,两种布局都命中。
    """
    try:
        r = subprocess.run([ffmpeg, "-hide_banner", "-i", mkv_path],
                           capture_output=True, timeout=30)
    except Exception:
        return None
    text = (r.stderr or b"").decode("utf-8", "ignore")
    for line in text.splitlines():
        m = re.match(r"\s*Stream #0:(\d+)(?:\([^)]*\))?:\s*Video:\s*ffv1", line)
        if m:
            return int(m.group(1))
    return None


def _iter_mkv_depth_frames_path(mkv_path: str) -> Iterator[np.ndarray]:
    """ffmpeg 流式解 MKV 的 FFV1 无损轨(gray16le → uint16 毫米帧)。"""
    ffmpeg = _get_ffmpeg()
    if not ffmpeg:
        return
    cap = cv2.VideoCapture(mkv_path)
    if not cap.isOpened():
        cap.release()
        return
    w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    cap.release()
    if w <= 0 or h <= 0:
        return
    # FFV1 轨流号探测:双流件(v1.0.14)= 1,迁移单流件 = 0;探测失败
    # 回退 0:1(v1.0.14 双流默认)。
    idx = _ffv1_stream_index(ffmpeg, mkv_path)
    stream = f"0:{idx}" if idx is not None else "0:1"
    proc = subprocess.Popen(
        [ffmpeg, "-hide_banner", "-i", mkv_path,
         "-map", stream, "-f", "rawvideo", "-pix_fmt", "gray16le", "-"],
        stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    frame_bytes = w * h * 2
    buf = b""
    while proc.poll() is None:
        chunk = proc.stdout.read(65536)
        if not chunk:
            break
        buf += chunk
        while len(buf) >= frame_bytes:
            yield np.frombuffer(buf[:frame_bytes], "<u2").reshape(h, w).copy()
            buf = buf[frame_bytes:]
    proc.stdout.close()
    proc.wait()


def _iter_mp4_depth_frames_path(mp4_path: str) -> Iterator[np.ndarray]:
    """ffmpeg 流式解 12-bit 灰度 MP4(gray12le → 反量化 → uint16 毫米)。

    探测容器内流是否为 gray12le(v1.0.14 合封失败回退产生的 8-bit
    热力图 mp4 无数据轨,产出空帧序列);其余 HEVC 位深不回退(
    写端只有 12-bit 一条路)。
    """
    if not _mp4_is_gray12(mp4_path):
        return
    ffmpeg = _get_ffmpeg()
    if not ffmpeg:
        return
    cap = cv2.VideoCapture(mp4_path)
    if not cap.isOpened():
        cap.release()
        return
    w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    cap.release()
    if w <= 0 or h <= 0:
        return
    proc = subprocess.Popen(
        [ffmpeg, "-hide_banner", "-i", mp4_path,
         "-f", "rawvideo", "-pix_fmt", "gray12le", "-"],
        stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    frame_bytes = w * h * 2
    buf = b""
    while proc.poll() is None:
        chunk = proc.stdout.read(65536)
        if not chunk:
            break
        buf += chunk
        while len(buf) >= frame_bytes:
            codes = np.frombuffer(buf[:frame_bytes], "<u2").reshape(h, w)
            buf = buf[frame_bytes:]
            yield dequantize_depth(codes)
    proc.stdout.close()
    proc.wait()


class Gray12DepthVideo:
    """12-bit 灰度 MP4 随机访问读取(回放用)。

    cv2 直读 gray12le 的 8-bit 转换不可靠(实测与规范码值映射
    mean 差 169/255),像素一律走 ffmpeg CLI 解 gray12le →
    codes_to_heatmap_bgr(规范 JET,与实时显示同构)。
    小步顺序读续用当前流;随机大跳用 -ss 输入快进(从最近关键帧
    解到目标,keyint~250 最坏数百帧,亚秒级)。
    """

    def __init__(self, path: str, width: int, height: int,
                 fps: float, total: int):
        self.path = path
        self.width = int(width)
        self.height = int(height)
        self.fps = float(fps) if fps > 0 else 30.0
        self.total = max(1, int(total))
        self._proc: Optional[subprocess.Popen] = None
        self._buf = b""
        self._next_idx = 0
        self._frame_bytes = self.width * self.height * 2

    @classmethod
    def from_path(cls, mp4_path: str) -> Optional["Gray12DepthVideo"]:
        """gray12le 且可探测尺寸/帧率的返回实例,否则 None。"""
        if not _mp4_is_gray12(mp4_path):
            return None
        w = h = 0
        fps = 0.0
        total = 0
        try:
            cap = cv2.VideoCapture(mp4_path)
            if cap.isOpened():
                w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
                h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
                fps = float(cap.get(cv2.CAP_PROP_FPS) or 0)
                total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
            cap.release()
        except Exception:
            pass
        if w > 0 and h > 0:
            return cls(mp4_path, w, h, fps, total)
        # cv2 探不开(灰度 12-bit 后端缺省)→ ffmpeg -i stderr 兜底
        ffmpeg = _get_ffmpeg()
        if not ffmpeg:
            return None
        try:
            r = subprocess.run([ffmpeg, "-hide_banner", "-i", mp4_path],
                               capture_output=True, timeout=30)
        except Exception:
            return None
        text = (r.stderr or b"").decode("utf-8", "ignore")
        m = re.search(r"gray12le[^\n]*?(\d{3,5})x(\d{3,5})[^\n]*?([\d.]+)\s+fps",
                      text)
        if not m:
            return None
        w, h = int(m.group(1)), int(m.group(2))
        fps = float(m.group(3))
        dm = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", text)
        if dm and fps > 0:
            total = int((int(dm.group(1)) * 3600 + int(dm.group(2)) * 60
                         + float(dm.group(3))) * fps)
        return cls(mp4_path, w, h, fps, total)

    def read(self, idx: int) -> Optional[np.ndarray]:
        """读取帧 idx(0-based)→ BGR 热力图;越界/EOF 返回 None。"""
        if idx >= self.total:
            return None
        if self._proc is None or self._proc.poll() is not None:
            self._open(idx)
        while self._next_idx < idx:
            if self._pull() is None:
                return None
        return self._pull()

    def close(self) -> None:
        if self._proc is not None:
            try:
                self._proc.kill()
            except Exception:
                pass
            try:
                self._proc.wait(timeout=3)
            except Exception:
                pass
            self._proc = None
        self._buf = b""

    def _open(self, idx: int) -> None:
        self.close()
        ffmpeg = _get_ffmpeg()
        if not ffmpeg:
            return
        cmd = [ffmpeg, "-hide_banner", "-nostdin"]
        if idx > 0:
            # -ss 输入快进:从 ss 前最近关键帧解码并丢弃;-0.5/fps 余量
            # 防 PTS 舍入把 idx-1 带出来(均匀 PTS 下首输出即帧 idx)
            ss = max(0.0, (idx - 0.5) / self.fps)
            cmd += ["-ss", f"{ss:.6f}"]
        cmd += ["-i", self.path, "-f", "rawvideo",
                "-pix_fmt", "gray12le", "-"]
        try:
            self._proc = subprocess.Popen(
                cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
        except Exception:
            self._proc = None
            return
        self._next_idx = idx

    def _pull(self) -> Optional[np.ndarray]:
        if self._proc is None:
            return None
        try:
            while len(self._buf) < self._frame_bytes:
                chunk = self._proc.stdout.read(65536)
                if not chunk:
                    return None  # EOF(流结束/解码失败)
                self._buf += chunk
            raw = self._buf[:self._frame_bytes]
            self._buf = self._buf[self._frame_bytes:]
            self._next_idx += 1
        except Exception:
            return None
        codes = np.frombuffer(raw, "<u2").reshape(self.height, self.width)
        return codes_to_heatmap_bgr(codes)


def _mp4_is_gray12(mp4_path: str) -> bool:
    """容器内视频流是否为 gray12le(stderr 解析;探测失败保守 False)。"""
    ffmpeg = _get_ffmpeg()
    if not ffmpeg:
        return False
    try:
        r = subprocess.run([ffmpeg, "-hide_banner", "-i", mp4_path],
                           capture_output=True, timeout=30)
    except Exception:
        return False
    text = (r.stderr or b"").decode("utf-8", "ignore")
    return bool(re.search(r"Video:\s*[^\n]*gray12le", text))


def _iter_png16_frames(episode_dir: str, depth_name: str) -> Iterator[np.ndarray]:
    pattern = os.path.join(episode_dir, "depth", depth_name, "*.png")
    for path in sorted(glob.glob(pattern)):
        yield cv2.imread(path, cv2.IMREAD_UNCHANGED)


def read_depth_frame(episode_dir: str, depth_name: str,
                     frame_index: int) -> Optional[np.ndarray]:
    """读取单帧(1-based 序号,与 RGB 帧号对齐);越界/缺失返回 None。

    新格式下为 O(N) 顺序解到目标帧(FFV1 无随机访问捷径);
    批量遍历请直接用 iter_depth_frames。
    """
    for i, frame in enumerate(iter_depth_frames(episode_dir, depth_name)):
        if i == frame_index - 1:
            return frame
    return None


# ═══════════════════════════════════════════════════════
#  池化布局(v1.1.0):(task_dir, depth_key, episode_index) 键控
# ═══════════════════════════════════════════════════════

def pooled_depth_mkv_path(task_dir: str, depth_key: str,
                          episode_index: int) -> Optional[str]:
    """episode N 的深度文件路径(videos/chunk-{c}/<depth_key>/episode-{f}.{ext})。

    新录制 = 12-bit 灰度 mp4(v1.1.2 起);历史 = mkv(v1.0.14 双流
    FFV1 / 迁移合成单流 FFV1)。无视频深度返回 None。
    """
    path = episode_video_files(task_dir, episode_index).get(depth_key)
    if path and os.path.splitext(path)[1].lower() in (".mp4", ".mkv"):
        return path
    return None


def iter_pooled_depth_frames(task_dir: str, depth_key: str,
                             episode_index: int) -> Iterator[np.ndarray]:
    """顺序产出 episode N 该深度槽的 uint16 毫米帧;无文件产出空。"""
    path = pooled_depth_mkv_path(task_dir, depth_key, episode_index)
    if not path:
        return
    if path.lower().endswith(".mp4"):
        yield from _iter_mp4_depth_frames_path(path)
    else:
        yield from _iter_mkv_depth_frames_path(path)


def read_pooled_depth_frame(task_dir: str, depth_key: str, episode_index: int,
                            frame_index: int) -> Optional[np.ndarray]:
    """池化单帧读取(1-based 序号,与 RGB 帧号对齐);越界/缺失返回 None。

    O(N) 顺序解到目标帧;批量遍历请直接用 iter_pooled_depth_frames。
    """
    for i, frame in enumerate(iter_pooled_depth_frames(
            task_dir, depth_key, episode_index)):
        if i == frame_index - 1:
            return frame
    return None