refactor(studio-2d): 二维图像单一后端 —— 视图模式由帧内容决定,四条路径共用一个渲染入口
- What:
1. 新增 `shared/.../acquisition/image_backend.py`(纯 NumPy,无 Qt):`ImageFrame`(底图 + flow/depth/
valid_mask + source/frame_id/t_rel_s/stale)、`ImagePlan`、`plan_image()`、`render_image()`,以及模式
常量(`arrows_pure`/`arrows_stitched`/`depth`/`recorded`)与来源常量(live/recompute/replay/preview)。
`plan_image()` 分两个问题回答:**可选项**由来源决定(live/recompute = 三种派生视图;录像/摄像头预览
= 只有 `recorded`),**这一帧能不能画**由 `missing` 说明(某帧缺 flow/depth 不会悄悄改掉操作者选的
视图,而是在页脚写一行)。`render_image()` 是唯一渲染入口:内部复用 `draw_arrow_display`/
`depth_map_to_bgr`/`flow_colormap`(公共签名不变),深度图**改用主题色带**(与面板色条同一张表——
原来窗口没传 colormap,深度图走 OpenCV JET 而色条画品牌色带,图例与图片两张表)。
2. `ui/arrow_panel.py`:新增 `bgr_to_qimage()`(全应用唯一一份 BGR→QImage,`app_window._bgr_to_qimage`
成为它的别名、`replay.py` 的重复实现删除);新增 `set_plan(plan)`/`plan_modes()`——模式集来自 plan,
`view_modes()` 在无 plan 时回退旧的预设式列表(`set_source_kind` 保留"命名/文案"职责,并支持
`recompute` 状态与 `panels.recompute_pipeline`);页脚新增一行 `note_label` 说明 plan(如
"录像只保存原始相机图像…"),箭头放大/有效区注解改由"本帧是否支持箭头视图"决定;
`set_view_mode()` 与 `_populate_view_combo()` 按当前模式集校验(不再只认三个常量,否则 `recorded`
会被强行改回 `arrows_pure`)。
3. `app_window.py`:新增唯一入口 `_show_image_frame(ImageFrame)`(plan → 渲染 → 一个 QImage 给面板),
**四条生产者全部改走它**:实时/重算(`_render_run_image`,来源按 `_recompute_running` 区分)、回放
(`_render_replay_frame` 用 `replay.video_frame_bgr()`,不再自己转 QImage)、摄像头预览
(`_on_preview_tick`)。`_render_run_image` 里那套按 `mode` 分支画箭头的代码整体删除。
4. `replay.py`:新增 `video_frame_bgr()`(按光标缓存 BGR),`video_image()` 保留为兼容薄封装;删除
本模块的 `_bgr_to_qimage`。
5. locale:新增 `panels.replay_raw_note`(录像只有原始图像的说明 + 指路「由视频重算」)、
`panels.frame_missing_note`(`{fields}` 参数化)、`panels.field_flow`/`panels.field_depth`、
`panels.recompute_pipeline(_tip)`(中英各一套)。
- Why: 用户报的两个 bug 之一——「历史回放面板的 2 维图只能显示原始采集图像,没有进行任何处理」,并建议
"每一个 2D 图的后端保持一致"。核对后确认根因是架构而非漏了一步渲染:二维有三个(实际四个)互不一致的
生产者——实时/重算走 `draw_arrow_display`/`depth_map_to_bgr`,回放与预览把 BGR 直接交给面板;模式集与
注解由**视图预设**决定(`arrow_panel.set_source_kind("replay")` 才切成 `recorded`),于是同一段视频在
"回放"和"重算"下是两个成像栈,且 `_bgr_to_qimage` 有两份实现。用户已选定"统一后端 + 一键重算(不改记录
格式)",本提交做前半段;"一键重算"的入口在下一提交。
- Verification:
· 新增 `shared/tests/test_image_backend.py` 15 项:plan 的三类来源(live/recompute 三种派生视图、
replay/preview 只有 recorded)、`missing`/`note_key`/`note_fields`、模式回退、**与旧渲染逐像素一致**
(arrows_pure/arrows_stitched 对 `draw_arrow_display`、depth 对 `depth_map_to_bgr` 带主题色带)、
原始帧不做缩放/不替图、缺 depth → None、缺 flow → 底图、灰度帧只在这里加宽一次。
· `apps/studio/tests/test_ui_smoke.py` +2:同一段视频在回放路径只出 `recorded` 且有说明行、在重算/实时
路径出三种派生视图且都经过同一个 `render_image`(用 spy 断言来源与模式)、面板 `note_label` 不是裸键、
combo 项数与模式集一致;以及"全应用只有一处 BGR→QImage"的结构断言。
· 既有 `test_depth_view_reports_missing_data_and_fixes_its_scale`(缺深度必须报"本帧没有深度数据"而不是
回退显示普通形变图)继续通过——新的"缺字段说明 + 不回退替图"语义与它一致。
· 全量:apps/studio 236 项 0 失败 14 跳过;shared 331 项 0 失败 1 跳过;ui_check 587 项 0 失败;
locale_check 0 缺失。
- Not in this change: 「一键重算」按钮与"实时界面禁止回放入口"(7.2/7.3);深度图改用主题色带改变了 depth
视图的颜色(这是修正:色条一直画的就是主题色带);三维(scene3d)未动;录像格式未动(仍只存原始相机图像)。
This commit is contained in:
@@ -2151,6 +2151,102 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, record_root):
|
||||
"""Stage 7: one renderer for every 2D producer, and modes from the frame itself.
|
||||
|
||||
A recording stores the raw camera image and nothing else, so its plan offers exactly
|
||||
one view and says why; a recomputed (or live) frame offers the derived views and goes
|
||||
through the *same* ``render_image``. Before this, the replay path handed a QImage
|
||||
straight to the panel while live/recompute went through two different drawing calls.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
import orisys_studio.app_window as app_module
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
DERIVED_MODES,
|
||||
ImageFrame,
|
||||
SOURCE_LIVE,
|
||||
SOURCE_RECOMPUTE,
|
||||
)
|
||||
|
||||
bare = Path(record_root) / "backend_video.mp4"
|
||||
writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48))
|
||||
for index in range(30):
|
||||
writer.write(np.full((48, 64, 3), index * 8, dtype=np.uint8))
|
||||
writer.release()
|
||||
assert bare.is_file()
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
original = app_module.render_image
|
||||
seen: list = []
|
||||
|
||||
def spy(frame, plan=None, **kwargs):
|
||||
seen.append((frame.source, kwargs.get("mode")))
|
||||
return original(frame, plan, **kwargs)
|
||||
|
||||
app_module.render_image = spy
|
||||
try:
|
||||
window._apply_view_preset("replay")
|
||||
assert window._load_replay_video(bare) is True
|
||||
window._render_replay_frame()
|
||||
QApplication.processEvents()
|
||||
assert window.arrow_panel.plan_modes() == ("recorded",), (
|
||||
"a recording must not offer a derived view"
|
||||
)
|
||||
assert seen and seen[-1][0] == "replay"
|
||||
note = window.arrow_panel.note_label.text()
|
||||
assert note and note != "panels.replay_raw_note", note
|
||||
assert window.arrow_panel.view_combo.count() == 1
|
||||
|
||||
# The pipeline drawing over the same footage: derived modes, same backend.
|
||||
window.arrow_panel.set_source_kind("recompute")
|
||||
shape = (48, 64)
|
||||
window._show_image_frame(
|
||||
ImageFrame(
|
||||
np.zeros((*shape, 3), dtype=np.uint8),
|
||||
flow=np.ones((*shape, 2), dtype=np.float32),
|
||||
depth=np.zeros(shape, dtype=np.float32),
|
||||
source=SOURCE_RECOMPUTE,
|
||||
frame_id=7,
|
||||
)
|
||||
)
|
||||
assert window.arrow_panel.plan_modes() == DERIVED_MODES
|
||||
assert seen[-1] == ("recompute", "arrows_pure")
|
||||
assert window.arrow_panel.view_combo.count() == len(DERIVED_MODES)
|
||||
|
||||
# And the live packet path is the same sink (through the same spy).
|
||||
window._show_image_frame(
|
||||
ImageFrame(
|
||||
np.full((*shape, 3), 9, dtype=np.uint8),
|
||||
flow=np.ones((*shape, 2), dtype=np.float32),
|
||||
source=SOURCE_LIVE,
|
||||
frame_id=8,
|
||||
)
|
||||
)
|
||||
assert seen[-1][0] == "live"
|
||||
finally:
|
||||
app_module.render_image = original
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_application_converts_bgr_to_qimage_in_one_place():
|
||||
"""Two conversions is how the same frame looks different on two paths."""
|
||||
import orisys_studio.app_window as app_module
|
||||
import orisys_studio.ui.arrow_panel as arrow_panel
|
||||
|
||||
assert app_module._bgr_to_qimage is arrow_panel.bgr_to_qimage
|
||||
root = Path(app_module.__file__).resolve().parent
|
||||
others = sorted(
|
||||
path.name
|
||||
for path in root.rglob("*.py")
|
||||
if path.name != "arrow_panel.py"
|
||||
and "def bgr_to_qimage" in path.read_text(encoding="utf-8")
|
||||
)
|
||||
assert others == [], f"another BGR→QImage conversion exists: {others}"
|
||||
|
||||
|
||||
def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
|
||||
make_window, record_root
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user