Store the depth channel in an analysis result and serve it to the 2D depth view
A replay could draw arrows but not depth, and the recording view said there was nothing to draw even when a result existed. Depth is now quantised per frame (14 B empty, 486 B at the worst frame seen), read from the cache by the same draw path live uses, and the raw-recording note appears only when no result exists. Format version 2; see apps/studio/docs/replay-analysis.md.
This commit is contained in:
@@ -190,7 +190,7 @@
|
||||
"view_mode_tip": "Image display mode while running",
|
||||
"replay_pipeline": "Recorded video (raw camera image)",
|
||||
"replay_pipeline_tip": "Replay shows the recorded raw camera image with no live arrow overlay; to check the algorithm, use View → Recompute forces from the video",
|
||||
"replay_raw_note": "A recording stores only the raw camera image: there is no flow or depth to draw. Use “Recompute from the video” for the algorithm's output",
|
||||
"replay_raw_note": "A recording stores only the raw camera image: there is no flow or depth to draw, because this recording has no analysis result yet. Opening a recording runs the pass automatically, or press “Recompute from the video”; once a result exists this pane can draw arrows and the depth map",
|
||||
"frame_missing_note": "This frame carries no {fields}: there is nothing for this view to draw",
|
||||
"field_flow": "flow",
|
||||
"field_depth": "depth",
|
||||
@@ -201,6 +201,7 @@
|
||||
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
|
||||
"depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)",
|
||||
"depth_scale": "Depth scale {low} – {high}",
|
||||
"depth_empty": "This frame's depth is all zeros (0 of {pixels} px non-zero): the ramp's low colour is a real zero response, not missing data",
|
||||
"arrow_scale": "Arrows: displacement ×{factor}",
|
||||
"arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).",
|
||||
"arrow_pipeline": "Captured image → algorithm output",
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
"view_mode_tip": "运行时的图像显示模式",
|
||||
"replay_pipeline": "录制视频(原始相机图像)",
|
||||
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要复核算法请用「视图 → 由视频重算力值」",
|
||||
"replay_raw_note": "录像只保存原始相机图像:这里没有光流/深度可画。要看算法处理结果,请用「由视频重算」",
|
||||
"replay_raw_note": "录像只保存原始相机图像:这里没有光流/深度可画,因为这个录像还没有分析结果。打开录像会自动跑一遍分析,也可以点「由视频重算」手动生成;有结果后本面板就能画箭头和深度图",
|
||||
"frame_missing_note": "本帧没有{fields}:这个视图这一帧没有数据可画",
|
||||
"field_flow": "光流",
|
||||
"field_depth": "深度",
|
||||
@@ -201,6 +201,7 @@
|
||||
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
||||
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
|
||||
"depth_scale": "深度色标 {low} – {high}",
|
||||
"depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标低端色是一帧真实的零响应,不是缺数据",
|
||||
"arrow_scale": "箭头按位移放大 ×{factor}",
|
||||
"arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。",
|
||||
"arrow_pipeline": "原始图像 → 算法输出",
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
|
||||
| 模式 | 画面来源 | 说明 |
|
||||
|---|---|---|
|
||||
| `recording` | 录像自己的 `raw_*.mp4`,按需解码 | 只有原始相机图像:没有光流,也没有深度 |
|
||||
| `analysis` | 存下来的分析结果,按帧读 | 有 pass 记录下来的光流样本与候选网格 |
|
||||
| `recording` | 录像自己的 `raw_*.mp4`,按需解码 | 这个画面是原始相机图像:它自己没有光流也没有深度 |
|
||||
| `analysis` | 存下来的分析结果,按帧读 | 有 pass 记录下来的光流样本、候选网格与深度图 |
|
||||
|
||||
模式说的是**画面从哪里读**,而"有没有可画的东西"是另一件事:只要有结果,二维面板的模式
|
||||
选择器就提供 深度图/箭头(时间轴在录像上时连"原始图像"一起提供),选了派生模式的那些帧
|
||||
一律从结果读、按窗口唯一的 2D 出口画 —— 绝不会把原始图像当成箭头图交出去。
|
||||
|
||||
图表、二维画布、三维面板都读这一个游标,模式由 `ReplayEngine.mode()` 回答;「从视频重算」
|
||||
因此不再是第三种来源,而是**显式重跑**这一遍的开关(第 10 节)。时间基准是**信号时间**
|
||||
@@ -47,9 +51,16 @@
|
||||
| 视图 | 读什么 | 怎么读 |
|
||||
|---|---|---|
|
||||
| 图表 | `forces.csv`(记录值) | `charts.set_cursor(t)` 用同一游标;结果里不存力 |
|
||||
| 二维画布 | 结果的 `flow` 与 `valid_mask` | `ImageFrame(source="analysis")` 走窗口唯一的 2D 出口 `_show_image_frame`,箭头、暗点、配色与域边界与实时/重算**同一套代码** |
|
||||
| 二维画布 | 结果的 `flow`、`depth` 与 `valid_mask` | `ImageFrame(source="analysis")` 走窗口唯一的 2D 出口 `_show_image_frame`,箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** |
|
||||
| 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 |
|
||||
|
||||
**深度视图也由结果喂。** `AnalysedFrame.depth` 就是第 1 节那幅量化平面重建出来的
|
||||
`float32` 图(空帧是形状正确的全零),`depth_valid` 因此是 `True` 而不像以前那样恒为
|
||||
`False`;深度着色、色标锁定、空/缺数据的区分全在窗口既有的那条路径上,回放没有第二条绘制
|
||||
代码。"这一帧深度是精确的零"由面板说出来(`panels.depth_empty`,带 `{pixels}`),与
|
||||
"这一帧根本没有深度通道"(`panels.depth_unavailable`)是两句不同的话,因为它们是两个不同的
|
||||
事实。
|
||||
|
||||
**画面是重建的,并且是精确重建。** 二维需要的 `[H,W,2]` 流场是把存下来的 `dx`/`dy` 写回
|
||||
**记录下来的候选位置**、其余格子填 `NaN`(选区器把非有限值当作非法,邻居格子因此不可能贡献
|
||||
出 pass 没记录过的样本)。写回要除以
|
||||
@@ -102,6 +113,22 @@ on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,
|
||||
`test_the_replay_reset_is_the_display_reset_and_never_the_device` 失败过 —— 合成录像的配置
|
||||
缺失让 pass 在断言之前结束并覆盖了 `复位显示` 的提示;修的是消息落在哪里。
|
||||
|
||||
**深度那一片加的验收**(同一个文件再 +8 项,另加 `shared/tests/test_analysis_depth.py` 10 项):
|
||||
|
||||
* **深度往返**:另跑一遍链路,逐帧比 `depth_map`,误差上界是该帧自己的 `(max − min) / 510`,
|
||||
在 box 录像(33 帧,1 帧非空、76 px)和 264 帧的 `long_8x.mp4`(241 帧空、23 帧非空、
|
||||
最坏 336 px)上都是 0 失败;非零计数、min/max 与链路的完全相等。
|
||||
* **跳转等于顺序播放(深度模式)**:把上面那条测试扩到深度 —— 顺序播放记下每帧渲染出的深度
|
||||
图,再跳到 5 个位置,帧 id、`depth`、统计与**渲染出的字节**逐项相等。
|
||||
* **尺寸**:空帧 4,048 B(固定 4,042 + 6 B 载荷头),最坏非空帧 4,534 B(+486 B);
|
||||
按实测比例 4,090 B/帧,十分钟 70.2 MB(上限 81.6 MB)。断言写死了这三个数。
|
||||
* **模式切换不写结果**:四个模式各画一次,`scan_results(cache)` 的目录集合不变。
|
||||
* **"没什么可画"只在该说的时候说**:无结果时 `plan_image` 只给 `recorded` 并带
|
||||
`replay_raw_note`;有结果时该行消失、四个模式都在,默认仍是原始图像。两种语言各断言一次,
|
||||
且文案必须点出产生结果的动作。
|
||||
* **派生模式走结果**:时间轴在录像上、模式选深度时,送到 2D 出口的是**结果那一帧**(含深度),
|
||||
不是解码出来的视频帧;切回"原始图像"仍是录像自己的画面。
|
||||
|
||||
真实 GL 快照(`QT_QPA_PLATFORM=windows` + `QT_OPENGL=desktop`,真实窗口路径、pass 写入
|
||||
scratch cache、游标停在存下来的帧 id 上):
|
||||
|
||||
@@ -115,11 +142,27 @@ scratch cache、游标停在存下来的帧 id 上):
|
||||
二维画面是候选网格上的暗点阵(该帧活动样本很少),三维是手指岛面加同一套样本点;"同一个样本
|
||||
集"由上面的逐元素断言保证,快照证明这条链路真的走到了 GL。
|
||||
|
||||
**深度那一片的快照**(`.scratch/replay-analysis-a2/snapshots/`,同一个窗口、同一个游标、
|
||||
同一个模式选择器;时间轴停在录像上,所以"原始图像"是真的相机图,两个派生模式读结果):
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `analysis-finger-frame-33-2d-raw.png` / `-arrows.png` / `-depth.png` | finger 链路帧 33,深度非零 76 px(峰值 0.0242) |
|
||||
| `analysis-box-frame-27-2d-raw.png` / `-arrows.png` / `-depth.png` | box 链路帧 27,`depth_map` 365×365 **恰好全零**,面板显示"本帧深度全为 0(133225 px 中 0 个非零)" |
|
||||
|
||||
同一帧三张图的大小不同(raw 96.6 kB 是相机图,arrows 4.9 kB,depth 4.2 kB),这本身就是
|
||||
"三个模式画的是三样东西"的证据。
|
||||
|
||||
## 15. 已知限制
|
||||
|
||||
* 结果里没有力,也没有深度:力曲线始终读记录的 `forces.csv`,深度视图在回放模式下不可用(没有
|
||||
可显示的数据,面板按空处理而不是画一张假的)。
|
||||
* 结果里没有力:力曲线始终读记录的 `forces.csv`。深度有了(第 1、11 节),但**三维面板仍只
|
||||
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False`,
|
||||
因为把深度接进三维会改变三维图形的构成,而那不在这一片里。二维深度视图是这一片交付的东西。
|
||||
* 结果覆盖的帧是 pass 走过的那些:录像比结果长时游标可以离开结果范围(面板说"这个分析结果里
|
||||
没有可显示的帧"),不会静默重复最后一帧。
|
||||
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
|
||||
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析。
|
||||
* 时间轴与模式选择是两件事,而"有没有结果"是录像的属性、不是游标的属性:
|
||||
`ReplayController.available_result()` 查的是**本录像**在 `ResultLibrary` 里的那一份,所以
|
||||
即使 `ReplayEngine.use_recording()` 让引擎不再借用 session(目前 UI 没有第二条路会走到
|
||||
这个状态),二维面板照样能画箭头和深度,`replay_raw_note` 也不会重新出现。
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
回放过去等于每移动一次光标就重跑一遍 SDK 链路:**每个视频秒 0.48–0.51 秒墙钟**,十分钟录像
|
||||
一次约 4.8 分钟。Part A 把这一遍跑一次,只存回放真正要画的东西。
|
||||
|
||||
格式版本从 1 升到 2:帧记录里多了深度通道。旧结果不会被误读 —— 版本不符直接
|
||||
`AnalysisFormatError`,重跑一遍即可(结果本来就是可重算的)。
|
||||
|
||||
## 1. 结果模型
|
||||
|
||||
原始输出每帧 **1,176,000 B**(`flow` 672,000 + `depth_map` 336,000 + `valid_mask` 84,000 +
|
||||
@@ -15,19 +18,34 @@
|
||||
`select_flow_samples` 选出的样本。
|
||||
|
||||
**每帧记录**(`frame_id`、`t_rel_s`、网格 keyframe 编号、候选数、深度统计、`dx`、`dy`、
|
||||
`magnitude`、位打包 `active`、掩码摘要)在 shipped finger 网格下是 **4,034 B/帧**:
|
||||
`24 + 330×12 + ⌈330/8⌉ + 8`;十分钟 30 fps 约 70 MB。选这个编码而不是实测的另外两种
|
||||
(float16 的 2,022 B/帧、丢掉 `magnitude` 的 4,002 B/帧),是因为往返必须**逐位相等**,而
|
||||
`magnitude` 正是"同一份结果换个阈值也能用"的依据(第 3 节)。`dx`/`dy`/`magnitude` 存
|
||||
`magnitude`、位打包 `active`、掩码摘要、深度载荷)在 shipped finger 网格下是 **4,048 B/帧**
|
||||
(空深度):`32 + 330×12 + ⌈330/8⌉ + 8 + 6`;十分钟 30 fps 约 70 MB。选这个编码而不是实测的
|
||||
另外两种(float16 的 2,022 B/帧、丢掉 `magnitude` 的 4,002 B/帧),是因为往返必须**逐位相等**,
|
||||
而 `magnitude` 正是"同一份结果换个阈值也能用"的依据(第 3 节)。`dx`/`dy`/`magnitude` 存
|
||||
float32,与实时选择器比对时后者也要转成 float32 才是精确比较。
|
||||
|
||||
**不存的**:力(`forces.csv` 已是图表读的内容,180.9 B/帧);深度图只存每帧一个
|
||||
`depth_nonzero` 计数(4 B)加汇总统计 —— 这段素材上深度几乎全零(264 帧中 241 帧全零,最大
|
||||
336 px / 84,000),统计记下来"深度视图是空的"才有解释。
|
||||
**深度存下来,而且是量化后存的。** 单帧 `depth_map` 336,000 B,十分钟 6.0 GB —— 直接存不可行;
|
||||
实测这段素材上它绝大多数帧**恰好全零**(264 帧的八倍速片段里 241 帧,box 录像 33 帧里 32 帧),
|
||||
最坏的非空帧也只有 84,000 px 中的 336 px。
|
||||
|
||||
所以每帧固定记下**深度通道的统计**(是否有通道、
|
||||
非零计数、最小/最大值,共 8 B)并附一个深度载荷:空帧只有一个 6 B 的载荷头(声明形状,
|
||||
指明"这一帧的深度是精确的零"),非空帧是**按该帧自己的 [min, max] 量化的 uint8 平面**再
|
||||
zlib level 6。
|
||||
|
||||
实测代价:空帧 +14 B,最坏非空帧 486 B;能在往返中引入的最大误差是半个量化步长
|
||||
`(max − min) / 510`,实测 6.6e-5(该帧峰值 0.034)。这不到深度视图所用 256 级色标的一步,所以
|
||||
重建出来的帧**不可能被染成与原帧不同的颜色**。
|
||||
|
||||
稀疏存储实测过并否决:每像素 4 B 索引在最坏帧上要 910 B,而整幅量化平面只要 486 B —— 一片几乎
|
||||
全零的字节平面在 zlib 下几乎免费,像素索引不是。限制在岛内也实测过并否决:418 B 对 486 B,
|
||||
在 4,048 B 的记录里省 5.2 B/帧,代价是重建要依赖有效性掩码 keyframe(box 链路根本没有),
|
||||
而且一旦有深度值落在岛外就会**静默丢掉一个测量**。
|
||||
|
||||
`provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
|
||||
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算,`forces` 记录)、`complete`
|
||||
标志与计数。
|
||||
标志与计数。`counts` 另记 `depth_frames` / `depth_nonzero_frames` / `depth_nonzero_max` /
|
||||
`depth_pixels` 与实测的 `frame_record_bytes`,进度面板和"这一帧为什么是空的"都读它。
|
||||
|
||||
## 2. Keyframe:候选位置与有效掩码
|
||||
|
||||
|
||||
@@ -2610,11 +2610,26 @@ class StudioMainWindow(QMainWindow):
|
||||
self._depth_range_value = (lo, hi)
|
||||
return self._depth_range_value
|
||||
|
||||
def _depth_scale_text(self) -> str:
|
||||
def _depth_scale_text(self, frame: ImageFrame | None = None) -> str:
|
||||
"""The colour scale, plus — when the frame's depth is exactly zero — why it is flat.
|
||||
|
||||
A depth view of an all-zero map is a flat island in the ramp's low colour, which
|
||||
looks the same as a broken view. The frame knows which it is: the pass stored the
|
||||
channel, its non-zero count and its range, so "0 of 84,000 pixels" can be said
|
||||
instead of leaving the operator to wonder. It is not the same statement as
|
||||
``panels.depth_unavailable``, which is for a frame that carries no depth at all.
|
||||
"""
|
||||
span = self._depth_range()
|
||||
if span is None:
|
||||
return ""
|
||||
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
|
||||
text = "" if span is None else t(
|
||||
"panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}"
|
||||
)
|
||||
if frame is None or frame.depth is None:
|
||||
return text
|
||||
array = np.asarray(frame.depth)
|
||||
if array.size and not bool(np.any(array)):
|
||||
empty = t("panels.depth_empty", pixels=int(array.size))
|
||||
return f"{text} · {empty}" if text else empty
|
||||
return text
|
||||
|
||||
def _latched_domain_mask(self, frame: ImageFrame):
|
||||
"""The 2D domain boundary to draw, latched for the session.
|
||||
@@ -2694,7 +2709,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
||||
if mode == DEPTH:
|
||||
self._last_depth_frame = frame.depth
|
||||
self.arrow_panel.set_depth_scale(self._depth_scale_text())
|
||||
self.arrow_panel.set_depth_scale(self._depth_scale_text(frame))
|
||||
else:
|
||||
self.arrow_panel.set_depth_scale(None)
|
||||
rendered = render_image(
|
||||
|
||||
@@ -110,6 +110,26 @@ class AnalysedFrame:
|
||||
base_bgr: np.ndarray
|
||||
depth_nonzero: int = 0
|
||||
dropped: bool = False
|
||||
#: The stored depth channel of this frame, or ``None`` when the pass recorded none.
|
||||
#: An all-zero map is a *measurement* — "the response was zero everywhere" — and is
|
||||
#: handed over as an array; the depth view draws it and the statistics that came
|
||||
#: with it say why it is flat. See ``analysis.model`` for the encoding.
|
||||
depth: np.ndarray | None = None
|
||||
depth_min: float = 0.0
|
||||
depth_max: float = 0.0
|
||||
|
||||
@property
|
||||
def has_depth(self) -> bool:
|
||||
return self.depth is not None
|
||||
|
||||
@property
|
||||
def depth_pixels(self) -> int:
|
||||
return 0 if self.depth is None else int(self.depth.size)
|
||||
|
||||
@property
|
||||
def depth_is_empty(self) -> bool:
|
||||
"""True when this frame has a depth channel and it is exactly zero everywhere."""
|
||||
return self.depth is not None and self.depth_nonzero == 0
|
||||
|
||||
@property
|
||||
def domain_hw(self) -> tuple[int, int]:
|
||||
@@ -342,6 +362,7 @@ class AnalysisSession:
|
||||
samples = payload.samples(threshold=self.threshold())
|
||||
flow = self._recover_flow(samples, shape_hw)
|
||||
base = np.zeros((shape_hw[0], shape_hw[1], 3), dtype=np.uint8)
|
||||
depth = getattr(payload, "depth", None)
|
||||
return AnalysedFrame(
|
||||
frame_id=int(payload.frame_id),
|
||||
t_rel_s=float(payload.t_rel_s),
|
||||
@@ -351,6 +372,9 @@ class AnalysisSession:
|
||||
base_bgr=base,
|
||||
depth_nonzero=int(getattr(payload, "depth_nonzero", 0) or 0),
|
||||
dropped=bool(getattr(payload, "dropped", False)),
|
||||
depth=None if depth is None else np.asarray(depth, dtype=np.float32),
|
||||
depth_min=float(getattr(payload, "depth_min", 0.0) or 0.0),
|
||||
depth_max=float(getattr(payload, "depth_max", 0.0) or 0.0),
|
||||
)
|
||||
|
||||
def _recover_flow(
|
||||
|
||||
@@ -28,7 +28,12 @@ from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.image_backend import SOURCE_ANALYSIS, SOURCE_REPLAY, ImageFrame
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
DERIVED_MODES,
|
||||
SOURCE_ANALYSIS,
|
||||
SOURCE_REPLAY,
|
||||
ImageFrame,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
@@ -891,17 +896,75 @@ class ReplayController(QObject):
|
||||
self._actions.recompute.blockSignals(False)
|
||||
self._toolbar.set_recompute_checked(bool(value))
|
||||
|
||||
def available_result(self):
|
||||
"""The stored result for the recording that is open, if there is one.
|
||||
|
||||
Deliberately not ``engine.analysis()``: that answers "which session is the
|
||||
*timeline* borrowing", and it is cleared when the timeline goes back to reading
|
||||
the video (``ReplayEngine.use_recording``). A result is a property of the
|
||||
recording, not of the cursor's mode, and it is the question the panel's one line
|
||||
has to be answered from — "is there a result?" is why the arrows and the depth
|
||||
map can be drawn at all. The session the library holds is checked against the
|
||||
recording that is open, so a previous recording's result cannot answer for this
|
||||
one.
|
||||
"""
|
||||
session = self._engine.analysis()
|
||||
if session is not None:
|
||||
return session
|
||||
held = self.results.session
|
||||
if held is None:
|
||||
return None
|
||||
current = self.recording_directory()
|
||||
stored = getattr(held, "recording", None)
|
||||
if current is None or stored is None or Path(stored) != Path(current):
|
||||
return None
|
||||
return held
|
||||
|
||||
# ------------------------------------------------------------------ drawing
|
||||
def _analysis_image_frame(self, frame, *, source: str) -> ImageFrame:
|
||||
"""One stored frame in the shape the window's single 2D sink takes.
|
||||
|
||||
The *only* place a replay builds a 2D frame from a result, so the analysis
|
||||
timeline and a recording whose mode selector asks for a derived view cannot
|
||||
build two slightly different pictures. ``source`` says which timeline the
|
||||
operator is on — ``replay`` keeps the raw view on offer while the derived modes
|
||||
are drawn from the result, so choosing a derived view is not a one-way door.
|
||||
"""
|
||||
return ImageFrame(
|
||||
frame.base_bgr,
|
||||
flow=frame.flow,
|
||||
depth=frame.depth,
|
||||
valid_mask=frame.valid_mask,
|
||||
source=source,
|
||||
frame_id=int(frame.frame_id),
|
||||
t_rel_s=float(frame.t_rel_s),
|
||||
flow_valid=True,
|
||||
flow_frame_id=int(frame.frame_id),
|
||||
depth_valid=bool(frame.has_depth),
|
||||
depth_frame_id=int(frame.frame_id) if frame.has_depth else None,
|
||||
# The result this frame came from is the thing that makes arrows and depth
|
||||
# drawable at all; the plan reads it to decide what to offer.
|
||||
result_available=True,
|
||||
)
|
||||
|
||||
def render_frame(self) -> None:
|
||||
"""Draw what the 2D panel should show for the current replay cursor.
|
||||
|
||||
One method for both timeline modes, because it is one cursor: in analysis mode
|
||||
the picture is the stored frame's own flow field (the pass's candidate
|
||||
locations, on the domain grid), and in recording mode it is the decoded video
|
||||
frame, which carries no flow and no depth — so the plan says so in one line and
|
||||
the panel offers exactly the one view the frame can be. The window's single 2D
|
||||
sink draws both through the *same* backend, so the arrows, the idle dots, the
|
||||
colour range and the domain boundary behave identically in the two modes.
|
||||
the picture is the stored frame's own flow field and depth map (the pass's
|
||||
candidate locations, on the domain grid), and in recording mode it is the
|
||||
decoded video frame. The window's single 2D sink draws both through the *same*
|
||||
backend, so the arrows, the idle dots, the colour range and the domain boundary
|
||||
behave identically in the two modes.
|
||||
|
||||
**A mode that needs derived data is served from the result, never faked.** The
|
||||
arrows and the depth map are the pass's measurements; with a result in hand they
|
||||
are read from it and drawn through this same path, from the cursor's own frame.
|
||||
The recording's own picture answers only for the mode that *is* the recording's
|
||||
picture, so no mode can silently present the raw image as an arrow plot. With no
|
||||
result there is nothing to serve: the plan offers the recorded view alone, and
|
||||
the panel says so — naming the action that produces a result — instead of leaving
|
||||
the operator to guess why the arrow view is empty.
|
||||
|
||||
Nothing here writes anything: a display change reaches this method through the
|
||||
engine's ``redraw()`` and rebuilds the picture from the same stored frame.
|
||||
@@ -911,30 +974,34 @@ class ReplayController(QObject):
|
||||
if self._recompute_running:
|
||||
# The pipeline is drawing its own frames (see ``owns_image``).
|
||||
return
|
||||
if self.analysis_active():
|
||||
frame = self._engine.analysed_frame()
|
||||
if frame is None:
|
||||
self._image_panel.clear_preview(self._engine.video_message())
|
||||
return
|
||||
self._host._show_image_frame(
|
||||
ImageFrame(
|
||||
frame.base_bgr,
|
||||
flow=frame.flow,
|
||||
valid_mask=frame.valid_mask,
|
||||
source=SOURCE_ANALYSIS,
|
||||
frame_id=int(frame.frame_id),
|
||||
t_rel_s=float(frame.t_rel_s),
|
||||
flow_valid=True,
|
||||
flow_frame_id=int(frame.frame_id),
|
||||
depth_valid=False,
|
||||
session = self.available_result()
|
||||
wanted = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
|
||||
if session is not None:
|
||||
if self.analysis_active():
|
||||
frame = self._engine.analysed_frame()
|
||||
if frame is None:
|
||||
self._image_panel.clear_preview(self._engine.video_message())
|
||||
return
|
||||
elif wanted in DERIVED_MODES:
|
||||
# The timeline reads the video, but this view is not the video: it is a
|
||||
# measurement, and the result is where measurements live.
|
||||
frame = session.frame_at(self._engine.position())
|
||||
if frame is None:
|
||||
self._image_panel.clear_preview(self._engine.video_message())
|
||||
return
|
||||
else:
|
||||
frame = None
|
||||
if frame is not None:
|
||||
self._host._show_image_frame(
|
||||
self._analysis_image_frame(
|
||||
frame,
|
||||
source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY,
|
||||
)
|
||||
)
|
||||
)
|
||||
session = self._engine.analysis()
|
||||
if session is not None:
|
||||
# The same frame, in the shape the 3D pane's producer consumes: one
|
||||
# cursor, two figures, one frame id.
|
||||
self._host._show_analysis_frame(session.packet(frame))
|
||||
return
|
||||
return
|
||||
image = self._engine.video_frame_bgr()
|
||||
if image is None:
|
||||
self._image_panel.clear_preview(self._engine.video_message())
|
||||
@@ -944,6 +1011,10 @@ class ReplayController(QObject):
|
||||
image,
|
||||
source=SOURCE_REPLAY,
|
||||
t_rel_s=float(self._engine.position()),
|
||||
# A result exists, so the derived views are offered and are drawn from
|
||||
# it (above). Without one there is nothing behind the picture and the
|
||||
# panel says exactly that.
|
||||
result_available=session is not None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +28,19 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr, flow_colormap
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
DERIVED_MODES,
|
||||
RAW_NOTE_KEY,
|
||||
SOURCE_ANALYSIS,
|
||||
SOURCE_REPLAY,
|
||||
ImageFrame,
|
||||
plan_image,
|
||||
render_image,
|
||||
)
|
||||
from orisys_app_shared.analysis.cache import ResultCache, scan_results
|
||||
from orisys_app_shared.analysis.reader import (
|
||||
AnalysisIncompleteError,
|
||||
@@ -254,10 +267,22 @@ class _ImagePanel:
|
||||
def __init__(self) -> None:
|
||||
self.kinds: list[str] = []
|
||||
self.cleared: list[str] = []
|
||||
#: The 2D mode selector's current value. ``recorded`` is what the panel starts
|
||||
#: on for a recording, so a stub that never sets it behaves as before.
|
||||
self._mode = "recorded"
|
||||
|
||||
def set_source_kind(self, kind: str) -> None:
|
||||
self.kinds.append(str(kind))
|
||||
|
||||
def view_mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
def set_view_mode(self, mode: str) -> None:
|
||||
self._mode = str(mode)
|
||||
|
||||
def set_plan(self, plan) -> None:
|
||||
self.plan = plan
|
||||
|
||||
def clear_preview(self, message: str) -> None:
|
||||
self.cleared.append(str(message))
|
||||
|
||||
@@ -904,3 +929,266 @@ def test_the_worker_reports_progress_and_a_cancelled_outcome():
|
||||
assert report.cancelled is True and report.complete is False
|
||||
assert report.frames_written == 2
|
||||
assert report_from(None, cancelled=True).cancelled is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the depth view
|
||||
def _analysis_frame_for(frame, *, source=SOURCE_ANALYSIS):
|
||||
"""The frame the controller hands the window's single 2D sink for a stored frame."""
|
||||
return ImageFrame(
|
||||
frame.base_bgr,
|
||||
flow=frame.flow,
|
||||
depth=frame.depth,
|
||||
valid_mask=frame.valid_mask,
|
||||
source=source,
|
||||
frame_id=int(frame.frame_id),
|
||||
t_rel_s=float(frame.t_rel_s),
|
||||
flow_valid=True,
|
||||
flow_frame_id=int(frame.frame_id),
|
||||
depth_valid=bool(frame.has_depth),
|
||||
depth_frame_id=int(frame.frame_id) if frame.has_depth else None,
|
||||
result_available=True,
|
||||
)
|
||||
|
||||
|
||||
def _drawn(frame, mode: str):
|
||||
image = _analysis_frame_for(frame)
|
||||
return render_image(
|
||||
image, plan_image(image), mode=mode, theme="dark", draw_style="box"
|
||||
)
|
||||
|
||||
|
||||
def test_the_depth_view_is_drawn_from_the_cache_by_the_backend_live_uses(session):
|
||||
"""The depth mode renders the stored map through the same call live renders through.
|
||||
|
||||
Nothing here is a second draw path: ``render_image`` in ``depth`` mode is the same
|
||||
function a live frame reaches, over the map the reader handed back. The check that it
|
||||
is really that map is a direct comparison with the shared converter, and the check
|
||||
that it is not some other view is that the arrows picture differs from it.
|
||||
"""
|
||||
frame = session.frame(FIRST_FRAME_ID + 1)
|
||||
assert frame.has_depth, "the pass recorded no depth channel for this frame"
|
||||
assert frame.depth.shape == (300, 280)
|
||||
assert frame.depth.dtype == np.float32
|
||||
|
||||
drawn = _drawn(frame, DEPTH)
|
||||
assert drawn is not None
|
||||
expected = depth_map_to_bgr(
|
||||
frame.depth, flow_colormap("dark"), value_range=None, invalid_mask=frame.valid_mask
|
||||
)
|
||||
assert np.array_equal(drawn, expected), "the depth mode did not draw the stored map"
|
||||
arrows = _drawn(frame, ARROW_PURE)
|
||||
assert arrows is not None
|
||||
assert not np.array_equal(drawn, arrows), "the depth view drew the arrow view"
|
||||
|
||||
|
||||
def test_a_seek_equals_playing_forward_in_the_depth_view(session):
|
||||
"""The seek test, in the depth mode: same frame, same map, same bytes on screen.
|
||||
|
||||
Playing forward records each frame's rendered depth picture; a fresh session is then
|
||||
seeked to five stored timestamps and must produce those pictures byte for byte. The
|
||||
tolerance is zero on the frame id, on ``depth``, on the statistics that travel with
|
||||
it and on the rendered image — because a seek is a file read of the same record.
|
||||
"""
|
||||
ids = [int(value) for value in session.frame_ids]
|
||||
played: dict[int, tuple] = {}
|
||||
for frame_id in ids:
|
||||
frame = session.frame(frame_id)
|
||||
played[frame_id] = (
|
||||
frame.depth,
|
||||
frame.depth_nonzero,
|
||||
frame.depth_min,
|
||||
frame.depth_max,
|
||||
_drawn(frame, DEPTH),
|
||||
)
|
||||
fresh = AnalysisSession(
|
||||
AnalysisReader(_result_directory(Path(session.directory).parent)),
|
||||
policy=session.policy,
|
||||
display_policy=session.display_policy,
|
||||
)
|
||||
try:
|
||||
nonempty = 0
|
||||
for frame_id in (ids[0], ids[1], ids[len(ids) // 2], ids[-2], ids[-1]):
|
||||
moment = fresh.time_of(frame_id)
|
||||
assert moment is not None
|
||||
seeked = fresh.frame_at(moment)
|
||||
assert seeked is not None and seeked.frame_id == frame_id
|
||||
depth, nonzero, low, high, picture = played[frame_id]
|
||||
nonempty += 1 if nonzero else 0
|
||||
assert seeked.depth_nonzero == nonzero
|
||||
assert (seeked.depth_min, seeked.depth_max) == (low, high)
|
||||
assert np.array_equal(np.asarray(seeked.depth), np.asarray(depth))
|
||||
picture_now = _drawn(seeked, DEPTH)
|
||||
if picture is None or picture_now is None:
|
||||
assert picture is None and picture_now is None
|
||||
else:
|
||||
assert np.array_equal(picture_now, picture)
|
||||
assert nonempty <= 5
|
||||
finally:
|
||||
fresh.reader.close()
|
||||
|
||||
|
||||
def test_a_mode_change_writes_no_analysis(pass_root, session):
|
||||
"""Switching the 2D mode is a read: the result directory set cannot move.
|
||||
|
||||
The depth map is in the result, so drawing it is a file read like the arrows are.
|
||||
The assertion is the set of result directories before and after every mode the
|
||||
selector offers is selected, drawn and switched away from.
|
||||
"""
|
||||
before = {entry.directory.name for entry in scan_results(pass_root)}
|
||||
for mode in (DEPTH, ARROW_PURE, ARROW_STITCHED, "recorded"):
|
||||
frame = session.frame(FIRST_FRAME_ID + 1)
|
||||
if mode != "recorded":
|
||||
assert _drawn(frame, mode) is not None
|
||||
assert {entry.directory.name for entry in scan_results(pass_root)} == before
|
||||
|
||||
|
||||
def test_the_depth_statistics_say_which_frames_are_empty(session):
|
||||
"""An empty depth view is explainable: the frame says so, and so does the result."""
|
||||
frames = list(session.frames())
|
||||
emptied = [item for item in frames if item.depth_is_empty]
|
||||
carrying = [item for item in frames if item.depth_nonzero]
|
||||
assert len(emptied) + len(carrying) == len(frames)
|
||||
assert all(item.has_depth for item in frames), "a frame lost its depth channel"
|
||||
assert all(item.depth_pixels == 300 * 280 for item in frames)
|
||||
# Every observed frame on this footage falls in one of the two groups, and the empty
|
||||
# ones are exactly zeros rather than NaNs or a shape that failed to decode.
|
||||
for item in emptied:
|
||||
assert not bool(np.any(np.asarray(item.depth)))
|
||||
assert (item.depth_min, item.depth_max) == (0.0, 0.0)
|
||||
for item in carrying:
|
||||
assert float(np.asarray(item.depth).max()) == item.depth_max > 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------- the operator's one line
|
||||
def _note_for(image: ImageFrame) -> str:
|
||||
plan = plan_image(image)
|
||||
return "" if not plan.note_key else t(plan.note_key, fields="flow、depth")
|
||||
|
||||
|
||||
def test_the_nothing_to_draw_line_appears_only_when_no_result_exists():
|
||||
"""The recording's note is an absence statement, and it must be true when it is made.
|
||||
|
||||
Two recording frames that differ in exactly one fact: whether a stored analysis
|
||||
result exists. Without one, the pane really can draw nothing derived, so the line
|
||||
appears and offers the recorded view alone. With one, the arrows and the depth map
|
||||
are two file reads away, so the line is gone — and it is gone because the modes are
|
||||
on offer, not because a message was suppressed.
|
||||
"""
|
||||
raw = ImageFrame(np.zeros((8, 8, 3), np.uint8), source=SOURCE_REPLAY)
|
||||
plan = plan_image(raw)
|
||||
assert plan.available_modes == ("recorded",)
|
||||
assert plan.note_key == RAW_NOTE_KEY
|
||||
assert plan.resolve(DEPTH) == "recorded", "a view that cannot be drawn was selected"
|
||||
assert plan.has_depth() is False and plan.has_flow() is False
|
||||
|
||||
backed = ImageFrame(
|
||||
np.zeros((8, 8, 3), np.uint8), source=SOURCE_REPLAY, result_available=True
|
||||
)
|
||||
plan = plan_image(backed)
|
||||
assert plan.note_key == "", "the pane still says there is nothing to draw"
|
||||
assert plan.available_modes == ("recorded", *DERIVED_MODES)
|
||||
assert plan.default_mode == "recorded", "the raw picture stopped being the default"
|
||||
for mode in DERIVED_MODES:
|
||||
assert plan.accepts(mode), f"{mode} is not on offer with a result in hand"
|
||||
|
||||
|
||||
def test_the_nothing_to_draw_line_names_the_action_that_produces_a_result():
|
||||
"""Both locales: the line says what to do, not merely that there is nothing."""
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
|
||||
seen = {}
|
||||
try:
|
||||
for locale in ("zh_CN", "en_US"):
|
||||
set_locale(locale)
|
||||
text = t(RAW_NOTE_KEY)
|
||||
seen[locale] = text
|
||||
# The action that produces a result is named: the pass runs on open, and the
|
||||
# toolbar action re-runs it.
|
||||
assert "由视频重算" in text or "Recompute from the video" in text, text
|
||||
assert (
|
||||
"分析" in text or "analysis result" in text
|
||||
), f"{locale} does not say a result is what is missing: {text}"
|
||||
finally:
|
||||
set_locale("zh_CN")
|
||||
assert seen["zh_CN"] != seen["en_US"]
|
||||
|
||||
|
||||
def test_the_empty_depth_note_is_not_the_missing_depth_note():
|
||||
"""Both locales keep the two apart: a flat map is a reading, an absent one is not."""
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
|
||||
try:
|
||||
for locale in ("zh_CN", "en_US"):
|
||||
set_locale(locale)
|
||||
empty = t("panels.depth_empty", pixels=84000)
|
||||
absent = t("panels.depth_unavailable")
|
||||
assert "84000" in empty or "84,000" in empty, empty
|
||||
assert empty != absent
|
||||
finally:
|
||||
set_locale("zh_CN")
|
||||
|
||||
|
||||
def test_a_derived_mode_is_served_from_the_result_even_on_the_recording_timeline(
|
||||
pass_root,
|
||||
):
|
||||
"""Through the controller: the depth mode draws the stored frame, not the video.
|
||||
|
||||
The timeline is put on the recording (which is the state the operator is in while a
|
||||
pass runs, and the state a chosen source leaves them in), and the mode selector is
|
||||
set to depth. What reaches the window's single 2D sink must be the result's own frame
|
||||
— with its depth map — and not the decoded video frame, which carries none.
|
||||
"""
|
||||
_require_inputs()
|
||||
reader = AnalysisReader(_result_directory(pass_root))
|
||||
session = AnalysisSession(
|
||||
reader,
|
||||
display_policy=policy_for_settings(DisplaySettings()),
|
||||
# The recording the result describes. ``available_result`` matches it against the
|
||||
# recording that is open, which is how a previous recording's result cannot
|
||||
# answer for this one.
|
||||
recording=RECORDING,
|
||||
)
|
||||
try:
|
||||
panel = _ImagePanel()
|
||||
controller, host, engine, _ = _controller(image_panel=panel)
|
||||
controller.results.open_session(session)
|
||||
engine.use_analysis(session)
|
||||
engine._mode = MODE_RECORDING # the timeline reads the video; the view does not
|
||||
engine.set_position(session.time_of(FIRST_FRAME_ID + 1))
|
||||
host.frames.clear()
|
||||
|
||||
panel.set_view_mode(DEPTH)
|
||||
controller.render_frame()
|
||||
assert host.frames, "the depth mode drew nothing"
|
||||
drawn = host.frames[-1]
|
||||
assert drawn.source == SOURCE_REPLAY, "the recording timeline was not kept"
|
||||
assert drawn.result_available is True
|
||||
stored = session.frame(FIRST_FRAME_ID + 1)
|
||||
assert drawn.frame_id == stored.frame_id
|
||||
assert np.array_equal(np.asarray(drawn.depth), np.asarray(stored.depth))
|
||||
assert drawn.depth_valid is True
|
||||
assert plan_image(drawn).note_key == "", "the pane despaired with a result in hand"
|
||||
|
||||
# And the raw view still shows the recording's own picture.
|
||||
panel.set_view_mode("recorded")
|
||||
host.frames.clear()
|
||||
controller.render_frame()
|
||||
assert host.frames[-1].depth is None
|
||||
assert host.frames[-1].source == SOURCE_REPLAY
|
||||
|
||||
# Now through the engine's own way back to the video: it forgets the session it
|
||||
# was borrowing, and the *library* still holds the result for this recording. The
|
||||
# derived mode must keep drawing from it — a result is a property of the
|
||||
# recording, not of which timeline the cursor is on.
|
||||
engine.use_recording()
|
||||
assert engine.analysis() is None
|
||||
panel.set_view_mode(DEPTH)
|
||||
host.frames.clear()
|
||||
controller.render_frame()
|
||||
assert host.frames[-1].depth is not None, "the depth view followed the borrow"
|
||||
assert np.array_equal(
|
||||
np.asarray(host.frames[-1].depth), np.asarray(stored.depth)
|
||||
)
|
||||
assert plan_image(host.frames[-1]).note_key == ""
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
@@ -285,6 +285,10 @@ class _Engine:
|
||||
def video_message(self) -> str:
|
||||
return "no frame"
|
||||
|
||||
def analysis(self):
|
||||
"""No stored result in this stub: the derived views fall back to the video."""
|
||||
return None
|
||||
|
||||
def set_unit(self, unit: str) -> None:
|
||||
self.units.append(str(unit))
|
||||
|
||||
@@ -411,7 +415,6 @@ def test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view(qt_app,
|
||||
bare.write_bytes(b"not really a video")
|
||||
engine = _Engine()
|
||||
controller, host, engine, parts = _controller(engine=engine)
|
||||
toolbar = parts[0]
|
||||
|
||||
host.view = "live"
|
||||
assert controller.load_video(bare) is False
|
||||
|
||||
Reference in New Issue
Block a user