File size: 5,396 Bytes
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""触觉阵列卡死 —— 设备停止更新,录制端却继续写。

为什么重要
----------
这是最难发现的一类:**录制链路完全正常**。帧数对得上、时间戳连续、阵列是完整
的 16×16、数值也在合理区间 —— 只是内容是**很久以前的那一帧**,一直没变。
事后看数据一切正常,只有对比相邻帧才发现它静止了。

判定口径(阈值有实测依据)
--------------------------
**静止本身不是问题**。实测设备更新率约 7.5Hz,而录制是 30fps,所以同一个值会
**连续保持 4 帧** —— 这是正常的,不是卡死。

  S80C 全部集:受压时的保持段长度**只有 4**(`{4}`,无其他取值)
  D435 全部集:没有受压保持段

(本文件统计的是"保持帧数"= 一个取值连续占了多少帧。同一现象另一种常见记法是
"相邻帧相等的对数",那个数是 3 —— 4 帧有 3 个间隔。两个数不矛盾,别混。)

所以阈值有充足余量:``warn_frames=40``(约 1.3 秒)已经远超正常值。取 40 而不是
"比 4 大一点",是给将来换更新率更低的设备留空间 —— 卡死检测宁可慢一点发现,
也不该把正常的低更新率设备全报一遍。

★ 只在**受压时**才判。全零段天然"完全相同"(都是零),把它算进去的话,一集里
  88% 都是全零帧的批次会报出几百帧的假静止(实测踩过:最长静止段 575 帧,全部
  来自全零段)。
"""

from __future__ import annotations

from app.processing.cleaning.checks import (
    CheckContext, CleaningCheck, register_check,
    Finding, PASS, WARN, FAIL,
)
from app.processing.cleaning.contract import Range

# 报告的区间数上限 —— 真卡死时可能几百段,全塞进报告没意义
_MAX_RANGES = 20


def _static_runs(values: tuple, min_length: int) -> list[tuple[int, int]]:
    """受压时完全相同的连续帧段 ``[(起始帧, 长度), ...]``。

    只统计**非空且有压力**的帧:全零段天然完全相同,算进来会造出大片假区间。
    """
    runs: list[tuple[int, int]] = []
    total = len(values)
    index = 0
    while index < total:
        # 向后找同一取值的最长延伸
        end = index
        while end + 1 < total and values[end + 1] == values[index]:
            end += 1
        length = end - index + 1
        frame = values[index]
        pressed = bool(frame) and any(value > 0.0 for value in frame)
        if length >= min_length and pressed:
            runs.append((index, length))
        index = end + 1
    return runs


@register_check
class GloveFrozenArrayCheck(CleaningCheck):
    slug = "glove.frozen_array"
    label = "Tactile Array Frozen"
    version = "1.0"
    description = "Array unchanged for a long stretch while pressed (device stopped updating)"

    requires_channels = ("tactile",)
    device_cards = ("glove_sensor",)
    default_params = {
        "warn_frames": 40,        # 约 1.3 秒 @30fps;实测正常值 3 帧
        "fail_frames": 300,       # 约 10 秒
    }
    default_severity = WARN

    def run(self, ctx: CheckContext) -> list[Finding]:
        findings: list[Finding] = []
        fps = float(ctx.fps or 0.0) or 30.0
        warn_frames = int(self.param(ctx, "warn_frames", 40))
        fail_frames = int(self.param(ctx, "fail_frames", 300))

        for stream in ctx.streams_of("tactile"):
            values = tuple(stream.series.get("tactile") or ())
            if not values:
                continue

            runs = _static_runs(values, warn_frames)
            if not runs:
                findings.append(self.finding(
                    ctx, PASS, stream=stream.key,
                    message=f"{stream.key}: no abnormal frozen stretch",
                    metrics={"frames": len(values), "warn_frames": warn_frames},
                ))
                continue

            longest = max(length for _, length in runs)
            status = FAIL if longest >= fail_frames else WARN
            ranges = tuple(
                Range(
                    start_sec=start / fps,
                    end_sec=(start + length - 1) / fps,
                    code=self.slug,
                    severity=FAIL if length >= fail_frames else WARN,
                    stream=stream.key,
                    channel="tactile",
                    start_frame=start,
                    end_frame=start + length - 1,
                    rule=self.slug,
                    message=f"array unchanged for {length} frames",
                    evidence={"frozen_frames": length},
                )
                for start, length in runs[:_MAX_RANGES]
            )

            findings.append(self.finding(
                ctx, status, stream=stream.key,
                message=(f"{stream.key}: {len(runs)} frozen stretches while "
                         f"pressed (longest {longest} frames ~= {longest / fps:.1f}s)"),
                metrics={
                    "frames": len(values),
                    "static_runs": len(runs),
                    "longest_run_frames": longest,
                    "longest_run_sec": round(longest / fps, 3),
                    "warn_frames": warn_frames,
                    "fail_frames": fail_frames,
                },
                ranges=ranges,
            ))

        return findings