修复 Studio 失败测试并新增合并门禁
把丢失的 box 录像放回 apps/studio/tests/data/,修掉第 3 节 3–10 号与两处过时测试桩, 注册 timing 标记并让根目录收集零错误;新增 tools/gate.ps1 与可选的 pre-push 钩子。 验证:studio 524 通过 19 跳过、shared 467 通过 18 跳过、ui_check 632 项 0 失败、gate.ps1 退出码 0。 细节见 docs/test-repair-plan.zh.md。
This commit is contained in:
@@ -143,7 +143,7 @@
|
||||
"reset_display": "Reset display",
|
||||
"reset_display_tip": "Clear the accumulated display: the curves, cursor and value cards go back to their defaults and the replay returns to the start of the recording. There is no device session in this view, so nothing of the device is touched; the 3D camera is View ▸ Reset views (F8).",
|
||||
"pause": "⏸ Pause",
|
||||
"pause_tip": "Pause computation; the picture holds and the device stays connected (F7).",
|
||||
"pause_tip": "Pause computation; the picture holds and the device stays connected (F7). A pause leaves a gap in the recording.",
|
||||
"resume_tip": "Resume the computation and the display (F7), from the newest frame.",
|
||||
"resume_label": "▶ Resume",
|
||||
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
"reset_display": "复位显示",
|
||||
"reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。",
|
||||
"pause": "⏸ 暂停",
|
||||
"pause_tip": "暂停计算,画面停住,设备保持连接(F7)。",
|
||||
"pause_tip": "暂停计算,画面停住,设备保持连接(F7);跨过暂停的录制会留空档。",
|
||||
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
|
||||
"resume_label": "▶ 继续",
|
||||
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
|
||||
|
||||
@@ -264,6 +264,16 @@
|
||||
"condition": "sensor_dialog_open",
|
||||
"purpose": "the pickers' dialog plus the calibration facts that follow them; non-modal and built on first use",
|
||||
"elements": [
|
||||
{ "id": "dialog_profile_scroll", "path": "sensor_dialog.profile_scroll", "objectName": "sensorProfileScroll", "class": "QScrollArea",
|
||||
"purpose": "bounded scroll area for the selected profile's description and verdict: a long warning list stays readable without growing the dialog into the calibration section" },
|
||||
{ "id": "dialog_live_calibration_title", "path": "sensor_dialog.choice_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||
"purpose": "heading of the live-calibration choice, the section 3d2fb0c added so the running chain's calibration can be changed from the dialog" },
|
||||
{ "id": "dialog_live_calibration_combo", "path": "sensor_dialog.calibration_combo", "objectName": "liveCalibrationCombo", "class": "QComboBox",
|
||||
"purpose": "which calibration the live chain uses: the profile's declared file, a browsed one, or raw force; 应用 hands it to the session" },
|
||||
{ "id": "dialog_browse_calibration", "path": "sensor_dialog.btn_browse", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "pick a calibration file that is not the profile's declared one, and persist that choice" },
|
||||
{ "id": "dialog_device_edit", "path": "sensor_dialog.device_edit", "objectName": "liveDeviceEdit", "class": "QLineEdit",
|
||||
"purpose": "the Finger device serial the browsed calibration claims; shown only for a Finger file, and a mismatch is refused before it is applied" },
|
||||
{ "id": "dialog_calibration_title", "path": "sensor_dialog.calib_title", "objectName": null, "style": "panelTitle", "class": "QLabel",
|
||||
"purpose": "heading of the calibration half" },
|
||||
{ "id": "dialog_version_label", "path": "sensor_dialog.version_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel",
|
||||
@@ -283,7 +293,7 @@
|
||||
{ "id": "dialog_export", "path": "sensor_dialog.btn_export", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "write the calibration panel's coefficients (the panel's own action)" },
|
||||
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "push the last export into the running device" },
|
||||
"purpose": "hand the dialog's live-calibration choice to the session: remembered for the next run, and hot-updated into a device that is already acquiring (3d2fb0c moved it off the last export)" },
|
||||
{ "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton",
|
||||
"purpose": "keep the current sensor and calibration choice and dismiss the dialog" }
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ authors = [{ name = "Orisys-Tech" }]
|
||||
dependencies = [
|
||||
# PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not
|
||||
# on PyQt5 (whose free option is GPLv3-or-commercial). See
|
||||
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py.
|
||||
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_studio_binding_purity.py.
|
||||
"PySide6>=6.6",
|
||||
"orisys-app-shared>=0.1.0",
|
||||
"orisys>=0.5",
|
||||
|
||||
@@ -403,7 +403,31 @@ class AnalysisSession:
|
||||
if self._overlay is None:
|
||||
self._overlay_failed = True
|
||||
return None
|
||||
return self._overlay.project(video_bgr, frame_id=frame_id)
|
||||
projected = self._overlay.project(video_bgr, frame_id=frame_id)
|
||||
if projected is None:
|
||||
# Sticky, as the docstring above says. A projector that cannot map this
|
||||
# result's footage — measured: every frame of a real box recording, where the
|
||||
# stitch rebuild raises inside the SDK — will not start working mid-session,
|
||||
# and retrying per cursor move would rebuild the same failing stitch on every
|
||||
# scrub. The session records the answer so a caller can pick a view this
|
||||
# result *can* draw instead of leaving a refusal on screen.
|
||||
self._overlay_failed = True
|
||||
return projected
|
||||
|
||||
@property
|
||||
def overlay_unavailable(self) -> bool:
|
||||
"""Whether this result has been asked for an overlay base and could not give one.
|
||||
|
||||
The overlay views (``arrows_stitched``, ``depth_arrows``) paint on the same
|
||||
UV-unrolled or stitched picture live publishes, which a stored result does not
|
||||
contain: it is rebuilt from the result's provenance on first use. A **box**
|
||||
result has no stored unroll map, and the box stitch rebuild can fail on real
|
||||
footage, so the answer is only known after one attempt — and it is then sticky
|
||||
for the session (see :meth:`overlay_base`). The caller that *picks* the first
|
||||
view for an adopted result reads this, so the operator is not left on a view
|
||||
that cannot be drawn.
|
||||
"""
|
||||
return bool(self._overlay_failed)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the overlay projector; the reader is closed by :class:`ResultLibrary`."""
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DERIVED_MODES,
|
||||
RESULT_FAILED,
|
||||
@@ -1032,14 +1033,29 @@ class ReplayController(QObject):
|
||||
The first processed view is arrows over the unrolled / stitched base: the same
|
||||
texture live uses. A derived selection the operator already chose is left
|
||||
alone — that mode is not this method's to override.
|
||||
|
||||
**But only a view this result can actually draw.** The base is rebuilt from the
|
||||
result's provenance, which a box result cannot always do (no stored unroll map,
|
||||
and the box stitch rebuild can fail on real footage: measured, every frame of the
|
||||
restored 33-frame box recording). Offering the overlay there would leave the
|
||||
operator on a blank panel reading "capture unavailable" while the pure-colour
|
||||
arrows sit one step away, so the fallback is the drawable view — the overlay is
|
||||
still in the selector, and choosing it still says why it cannot be drawn.
|
||||
"""
|
||||
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
|
||||
if current in DERIVED_MODES:
|
||||
return False
|
||||
session = self.available_result()
|
||||
# The plan for the frame just drawn offers the derived modes (the frame carries
|
||||
# ``result_available``), so this selection is one the panel can honour.
|
||||
self._image_panel.set_view_mode(ARROW_STITCHED)
|
||||
overlay_failed = bool(getattr(session, "overlay_unavailable", False))
|
||||
self._image_panel.set_view_mode(ARROW_PURE if overlay_failed else ARROW_STITCHED)
|
||||
self.render_frame()
|
||||
if not overlay_failed and getattr(session, "overlay_unavailable", False):
|
||||
# The first attempt is what tells us: the base could not be rebuilt, so draw
|
||||
# the view that needs no base instead of leaving a refusal on screen.
|
||||
self._image_panel.set_view_mode(ARROW_PURE)
|
||||
self.render_frame()
|
||||
return True
|
||||
|
||||
def _display_policy(self) -> Any:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Plain helpers the studio UI tests share, importable without a ``tests`` package.
|
||||
|
||||
These four functions used to live in ``conftest.py`` and were imported as
|
||||
``from tests.conftest import ...``. That import only worked when pytest ran with
|
||||
``apps/studio`` in ``sys.path`` (or as the working directory): run from the
|
||||
repository root, the ``tests`` directory of *any* product could be shadowed, and
|
||||
the suite could not be collected at all. A module beside the tests, with the
|
||||
tests' own directory on ``sys.path``, has none of that ambiguity.
|
||||
|
||||
Fixtures stay in ``conftest.py`` (pytest discovers them); only the functions a
|
||||
test imports by name live here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def visible_descendants(widget):
|
||||
"""Yield every visible descendant widget of ``widget``."""
|
||||
for child in widget.findChildren(object):
|
||||
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
||||
continue
|
||||
if child.isVisible():
|
||||
yield child
|
||||
|
||||
|
||||
def show_window(window, size):
|
||||
"""Show a window at ``size`` and flush pending Qt events."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def running_window(make_window):
|
||||
"""Return a running window backed by a controllable test worker."""
|
||||
import ui_check
|
||||
|
||||
window = show_window(
|
||||
make_window(sensor_type="box", source_mode="camera"),
|
||||
(1440, 880),
|
||||
)
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window._start_run()
|
||||
window._run_timer.stop()
|
||||
window._set_phase("running")
|
||||
return window, stuck
|
||||
|
||||
|
||||
def finish_run(window, stuck):
|
||||
"""Stop a test run and release its controllable worker."""
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
@@ -51,6 +51,14 @@ _TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
|
||||
if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TOOLS_DIR))
|
||||
|
||||
# The tests' own directory, so ``_studio_helpers`` is importable however pytest was
|
||||
# started. Relying on pytest to insert it works only under the default import mode and
|
||||
# only for the directory a test file happens to sit in; naming it here is what lets the
|
||||
# suite be collected from the repository root (see ``docs/test-repair-plan.zh.md`` §4).
|
||||
_TESTS_DIR = Path(__file__).resolve().parent
|
||||
if str(_TESTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TESTS_DIR))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qt_app():
|
||||
@@ -117,49 +125,6 @@ def decline_video_prompt(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def visible_descendants(widget):
|
||||
"""Yield every visible descendant widget of ``widget``."""
|
||||
for child in widget.findChildren(object):
|
||||
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
||||
continue
|
||||
if child.isVisible():
|
||||
yield child
|
||||
|
||||
|
||||
def show_window(window, size):
|
||||
"""Show a window at ``size`` and flush pending Qt events."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window.resize(*size)
|
||||
window.show()
|
||||
QApplication.processEvents()
|
||||
return window
|
||||
|
||||
|
||||
def running_window(make_window):
|
||||
"""Return a running window backed by a controllable test worker."""
|
||||
import ui_check
|
||||
|
||||
window = show_window(
|
||||
make_window(sensor_type="box", source_mode="camera"),
|
||||
(1440, 880),
|
||||
)
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window._start_run()
|
||||
window._run_timer.stop()
|
||||
window._set_phase("running")
|
||||
return window, stuck
|
||||
|
||||
|
||||
def finish_run(window, stuck):
|
||||
"""Stop a test run and release its controllable worker."""
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
|
||||
|
||||
_RECORD_SCRATCH = (
|
||||
Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Test recordings
|
||||
|
||||
`box_short_20260930/` is one real recording, kept in the repository because several tests
|
||||
must drive the real SDK chain over real footage. A synthetic clip cannot replace it: the
|
||||
box chain's stitch probe looks for the lenslet grid in the image, does not converge on
|
||||
generated pixels, and a synthetic 896² clip sent the chain into minutes of work where this
|
||||
33-frame clip takes about two seconds.
|
||||
|
||||
## What it is
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Sensor | `box` (shipped bundle) |
|
||||
| Source | `dist/OrisysStudio/outputs/recordings/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011`, recorded 2026-09-30, `session_id` 2 |
|
||||
| Length | 33 frames, frame ids **20…52**, 1.92 s of signal time |
|
||||
| Files | `raw_0001.mp4`, `frames.csv`, `forces.csv`, `events.csv`, `session.json` (3.8 MB) |
|
||||
| Not included | `preroll_0001.mp4` (3.5 MB, optional for every reader: `RecordingReader.preroll_segment()` answers `None` when the file is absent) |
|
||||
|
||||
`dist/` is build output and is not tracked, so the copy under it disappeared on
|
||||
2026-10-14 when the studio was repackaged. That took 11 tests to `ERROR` and 4 to
|
||||
`failed` without anyone noticing — see
|
||||
[`docs/test-repair-plan.zh.md`](../../../docs/test-repair-plan.zh.md) §1 and §3 #1–#2.
|
||||
|
||||
The frame ids are deliberately not 0…32: a test that used the frame *index* would pass on
|
||||
a 0-based recording, and real recordings start wherever the device's counter was.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Do not replace it with a generated recording.** The chain's behaviour on these pixels
|
||||
is part of what the tests defend.
|
||||
- **Do not write into it from a test.** The tests that analyse it copy it into `tmp_path`
|
||||
first, because a pass writes its result beside the recording.
|
||||
- **If it goes missing, restore it or the tests fail.** They no longer skip on a missing
|
||||
input: a skip is how this went unnoticed.
|
||||
@@ -0,0 +1,3 @@
|
||||
at,t_rel_s,frame_id,kind,detail
|
||||
2026-09-30T01:50:28.848+00:00,,-1,record_started,mode=video_data
|
||||
2026-09-30T01:50:30.825+00:00,,-1,record_stopped,
|
||||
|
@@ -0,0 +1,34 @@
|
||||
session_id,frame_id,t_rel_s,FN_raw,FX_raw,FY_raw,FN_N,FX_N,FY_N,valid,calibration_id
|
||||
2,20,0.0,13567.7392578125,1180.1070556640625,9125.2607421875,0.555386962890625,0.006123639892578124,0.276258865234375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,21,0.064133,11343.3232421875,2028.0238037109375,7816.4375,0.444166162109375,0.034952809326171876,0.23175887499999998,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,22,0.112021,16884.728515625,745.5536499023438,7191.43896484375,0.72123642578125,0.0,0.2105089248046875,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,23,0.17623,14818.259765625,2178.03466796875,9171.318359375,0.61791298828125,0.0400531787109375,0.27782482421875,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,24,0.24043,14969.4208984375,-1287.072509765625,11873.1181640625,0.625471044921875,-0.00976046533203125,0.36968601757812497,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,25,0.304304,14659.830078125,-141.17251586914062,2469.820556640625,0.60999150390625,-0.0,0.049973898925781246,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,26,0.352255,15476.34375,-3281.966552734375,15066.1357421875,0.6508171875,-0.07758686279296875,0.478248615234375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,27,0.416172,11016.5068359375,-3127.916748046875,13734.294921875,0.427825341796875,-0.07234916943359375,0.43296602734375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,28,0.480077,14330.064453125,273.25970458984375,5539.169921875,0.59350322265625,0.0,0.15433177734375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,29,0.543786,14844.3466796875,2567.879150390625,5841.8525390625,0.619217333984375,0.05330789111328125,0.164622986328125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,30,0.591816,16986.365234375,-1623.703125,5377.94091796875,0.72631826171875,-0.02120590625,0.1488499912109375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,31,0.656365,13733.6103515625,-1030.4215087890625,9520.9345703125,0.563680517578125,-0.0010343312988281249,0.289711775390625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,32,0.720031,17051.982421875,-2590.688720703125,10953.798828125,0.72959912109375,-0.05408341650390625,0.33842916015625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,33,0.784173,16303.94921875,2727.433837890625,6891.0400390625,0.6921974609375,0.05873275048828125,0.20029536132812498,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,34,0.831679,17633.34375,-208.40782165527344,5174.18798828125,0.7586671875,-0.0,0.14192239160156248,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,35,0.896141,14191.8671875,-971.9476928710938,11894.728515625,0.586593359375,-0.0,0.37042076953124997,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,36,0.960091,15828.78125,-1548.9967041015625,9286.84765625,0.6684390625000001,-0.018665887939453125,0.2817528203125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,37,1.024524,18615.650390625,-3690.62451171875,10522.7822265625,0.80778251953125,-0.0914812333984375,0.323774595703125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,38,1.071701,15312.724609375,1154.6263427734375,11247.951171875,0.6426362304687501,0.005257295654296875,0.34843033984375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,39,1.135903,12159.138671875,-82.20235443115234,10962.3251953125,0.48495693359375003,-0.0,0.33871905664062496,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,40,1.199965,15671.615234375,-540.1160888671875,17301.103515625,0.66058076171875,-0.0,0.55423751953125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,41,1.264015,18193.279296875,-280.51025390625,7286.34765625,0.78666396484375,-0.0,0.2137358203125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,42,1.311786,14463.1240234375,23.800241470336914,14572.9287109375,0.6001562011718751,0.0,0.461479576171875,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,43,1.376278,14139.1728515625,549.9257202148438,14745.4931640625,0.583958642578125,0.0,0.467346767578125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,44,1.439864,14622.09375,535.4642944335938,11325.8857421875,0.6081046875,0.0,0.351080115234375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,45,1.503909,16870.396484375,-5440.37158203125,13050.87890625,0.72051982421875,-0.15097263378906248,0.4097298828125,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,46,1.551771,17459.4765625,-322.2771911621094,9053.3154296875,0.749973828125,-0.0,0.273812724609375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,47,1.616042,21696.408203125,-1986.04833984375,11181.533203125,0.96182041015625,-0.0335256435546875,0.34617212890625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,48,1.680262,15386.84765625,-4424.57763671875,1783.9827880859375,0.6463423828125,-0.11643563964843749,0.026655414794921874,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,49,1.744307,15400.05859375,-2761.5791015625,15228.455078125,0.6470029296875,-0.059893689453124996,0.48376747265625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,50,1.792308,14120.4208984375,116.24051666259766,9754.70703125,0.583021044921875,0.0,0.2976600390625,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,51,1.856446,15246.4052734375,-2575.3466796875,12753.658203125,0.639320263671875,-0.053561787109375,0.39962437890624997,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
2,52,1.920255,17219.171875,-6285.30517578125,13345.544921875,0.73795859375,-0.1797003759765625,0.41974852734375,1,171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348
|
||||
|
@@ -0,0 +1,34 @@
|
||||
frame_id,t_rel_s,acquired_at,segment,segment_frame,in_video,dropped
|
||||
20,0.0,2026-09-30T01:50:28.870+00:00,1,1,1,0
|
||||
21,0.064133,2026-09-30T01:50:28.935+00:00,1,2,1,0
|
||||
22,0.112021,2026-09-30T01:50:28.983+00:00,1,3,1,0
|
||||
23,0.17623,2026-09-30T01:50:29.048+00:00,1,4,1,0
|
||||
24,0.24043,2026-09-30T01:50:29.111+00:00,1,5,1,0
|
||||
25,0.304304,2026-09-30T01:50:29.175+00:00,1,6,1,0
|
||||
26,0.352255,2026-09-30T01:50:29.231+00:00,1,7,1,0
|
||||
27,0.416172,2026-09-30T01:50:29.287+00:00,1,8,1,0
|
||||
28,0.480077,2026-09-30T01:50:29.351+00:00,1,9,1,0
|
||||
29,0.543786,2026-09-30T01:50:29.414+00:00,1,10,1,0
|
||||
30,0.591816,2026-09-30T01:50:29.462+00:00,1,11,1,0
|
||||
31,0.656365,2026-09-30T01:50:29.526+00:00,1,12,1,0
|
||||
32,0.720031,2026-09-30T01:50:29.590+00:00,1,13,1,0
|
||||
33,0.784173,2026-09-30T01:50:29.654+00:00,1,14,1,0
|
||||
34,0.831679,2026-09-30T01:50:29.702+00:00,1,15,1,0
|
||||
35,0.896141,2026-09-30T01:50:29.766+00:00,1,16,1,0
|
||||
36,0.960091,2026-09-30T01:50:29.830+00:00,1,17,1,0
|
||||
37,1.024524,2026-09-30T01:50:29.895+00:00,1,18,1,0
|
||||
38,1.071701,2026-09-30T01:50:29.942+00:00,1,19,1,0
|
||||
39,1.135903,2026-09-30T01:50:30.006+00:00,1,20,1,0
|
||||
40,1.199965,2026-09-30T01:50:30.070+00:00,1,21,1,0
|
||||
41,1.264015,2026-09-30T01:50:30.134+00:00,1,22,1,0
|
||||
42,1.311786,2026-09-30T01:50:30.182+00:00,1,23,1,0
|
||||
43,1.376278,2026-09-30T01:50:30.247+00:00,1,24,1,0
|
||||
44,1.439864,2026-09-30T01:50:30.310+00:00,1,25,1,0
|
||||
45,1.503909,2026-09-30T01:50:30.373+00:00,1,26,1,0
|
||||
46,1.551771,2026-09-30T01:50:30.422+00:00,1,27,1,0
|
||||
47,1.616042,2026-09-30T01:50:30.486+00:00,1,28,1,0
|
||||
48,1.680262,2026-09-30T01:50:30.552+00:00,1,29,1,0
|
||||
49,1.744307,2026-09-30T01:50:30.615+00:00,1,30,1,0
|
||||
50,1.792308,2026-09-30T01:50:30.664+00:00,1,31,1,0
|
||||
51,1.856446,2026-09-30T01:50:30.728+00:00,1,32,1,0
|
||||
52,1.920255,2026-09-30T01:50:30.792+00:00,1,33,1,0
|
||||
|
Binary file not shown.
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"format_version": "1.1",
|
||||
"session_id": 2,
|
||||
"calibration_id": "171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348",
|
||||
"started_at": "2026-09-30T01:50:28+00:00",
|
||||
"ended_at": "2026-09-30T01:50:30+00:00",
|
||||
"video": {
|
||||
"video": true,
|
||||
"lossless_frames": false,
|
||||
"fps": 30.0,
|
||||
"codec": "mp4v",
|
||||
"segment_seconds": 300.0,
|
||||
"max_queue": 2048,
|
||||
"max_queue_bytes": 536870912,
|
||||
"preroll_frames": 30,
|
||||
"preroll_bytes": 201326592,
|
||||
"lossy_note": "MP4 is a lossy container: this is the pre-processing camera image, not a lossless record of the raw sensor data."
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"at": "2026-09-30T01:50:28.848+00:00",
|
||||
"mono_t": null,
|
||||
"frame_id": -1,
|
||||
"kind": "record_started",
|
||||
"detail": "mode=video_data"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-30T01:50:30.825+00:00",
|
||||
"mono_t": null,
|
||||
"frame_id": -1,
|
||||
"kind": "record_stopped",
|
||||
"detail": ""
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"frames": 33,
|
||||
"video_frames": 33,
|
||||
"segments": 1,
|
||||
"dropped_data": 0,
|
||||
"dropped_video": 0,
|
||||
"dropped_gaps": 0,
|
||||
"unrecorded_gaps": 0,
|
||||
"resampled_video": 0,
|
||||
"write_errors": 0,
|
||||
"failed": false,
|
||||
"last_error": "",
|
||||
"duration_s": 1.920255,
|
||||
"bytes_written": 7487099,
|
||||
"rejected_after_failure": 0
|
||||
},
|
||||
"calibration_snapshots": [],
|
||||
"preroll_frames": 30,
|
||||
"preroll_segment": "preroll_0001.mp4",
|
||||
"files": {
|
||||
"forces": "forces.csv",
|
||||
"frames": "frames.csv",
|
||||
"events": "events.csv",
|
||||
"video": "raw_%04d.mp4",
|
||||
"lossless_frames": null
|
||||
},
|
||||
"device": {
|
||||
"sensor_type": "box",
|
||||
"config_ref": "D:\\orisys_ws\\orisys-app\\dist\\OrisysStudio\\config\\sensors\\box\\sensor.json",
|
||||
"config_sha256": "27343dbbfc768c4d43ea48917ae2f9fbc9f91ba285b049b4c58a04673e3bec1e",
|
||||
"frame": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"measured_fps": 16.6642954760577
|
||||
}
|
||||
},
|
||||
"versions": {
|
||||
"app": "0.1.0",
|
||||
"shared": "0.1.0",
|
||||
"shared_api": "0.1",
|
||||
"sdk": "0.5.3"
|
||||
},
|
||||
"capture": {
|
||||
"requested": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.0,
|
||||
"fourcc": "MJPG"
|
||||
},
|
||||
"negotiated": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30.00003000003,
|
||||
"fourcc": "MJPG",
|
||||
"backend": "DSHOW"
|
||||
},
|
||||
"dropped_frames": 18,
|
||||
"measured_fps": 16.6642954760577
|
||||
},
|
||||
"calibration": {
|
||||
"config_ref": "D:\\orisys_ws\\orisys-app\\dist\\OrisysStudio\\config\\sensors\\box\\sensor.json",
|
||||
"force_calibrated": true,
|
||||
"version": "171e598ede7eaca213139b615dae0c5f86756256f0645b1dc5002fa3b810c348",
|
||||
"sn": "",
|
||||
"device_sn": ""
|
||||
},
|
||||
"depth": {
|
||||
"stored": false,
|
||||
"replay": "none",
|
||||
"quantity": "relative deformation / algorithm response, not a calibrated length",
|
||||
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
|
||||
"config_sha256": "27343dbbfc768c4d43ea48917ae2f9fbc9f91ba285b049b4c58a04673e3bec1e",
|
||||
"sdk_version": "0.5.3"
|
||||
},
|
||||
"conventions": {
|
||||
"axis_order": [
|
||||
"FN",
|
||||
"FX",
|
||||
"FY"
|
||||
],
|
||||
"force_unit": "N when calibrated, otherwise raw counts",
|
||||
"time_base": "t_rel_s is relative to the first recorded frame; an event that predates it has no relative base and is left empty",
|
||||
"frame_id": "monotonic per acquisition session"
|
||||
},
|
||||
"filter": {
|
||||
"measurement": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"flow_ema_applied": false,
|
||||
"hhd_downsample": 0,
|
||||
"dis_mode": 3
|
||||
},
|
||||
"readout": {
|
||||
"force_median_window": 1,
|
||||
"force_median_applied": false,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": 30.0,
|
||||
"force_lowpass_rate_auto": true,
|
||||
"force_lowpass_applied": false,
|
||||
"sample_rate_source": "config"
|
||||
},
|
||||
"describe": "none",
|
||||
"source": "sensor.filters_state",
|
||||
"show_unfiltered": false
|
||||
},
|
||||
"recording_mode": "video_data",
|
||||
"video_config": {
|
||||
"video": true,
|
||||
"lossless_frames": false,
|
||||
"fps": 30.0,
|
||||
"codec": "mp4v",
|
||||
"segment_seconds": 300.0,
|
||||
"max_queue": 2048,
|
||||
"max_queue_bytes": 536870912,
|
||||
"preroll_frames": 30,
|
||||
"preroll_bytes": 201326592,
|
||||
"lossy_note": "MP4 is a lossy container: this is the pre-processing camera image, not a lossless record of the raw sensor data."
|
||||
},
|
||||
"visualization3d": {
|
||||
"block_version": 1,
|
||||
"enabled": false,
|
||||
"available": false,
|
||||
"reason": "此传感器类型没有三维场景",
|
||||
"scene": {
|
||||
"path": "",
|
||||
"keys": "{'verify_key': False, 'content_key': False, 'require_signature': False}"
|
||||
},
|
||||
"gl": {},
|
||||
"display": {},
|
||||
"stream": {}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,10 @@ Where a failure would be invisible:
|
||||
* a box result is recorded on the box sample grid, so the canvas is compared with
|
||||
``draw_arrow_display`` on the recovered field.
|
||||
|
||||
The fixture clip is the project's recorded box footage under ``dist/``, copied into the test's
|
||||
own directory. If it is absent the test **fails** (``_require_clip``) rather than skipping: a
|
||||
silently skipped end-to-end test is how a broken workflow ships.
|
||||
The fixture clip is the project's recorded box footage, tracked in the repository beside
|
||||
this test (``tests/data/box_short_20260930``, see its README). If it is absent the test
|
||||
**fails** (``_require_clip``) rather than skipping: a silently skipped end-to-end test is
|
||||
how a broken workflow ships.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -65,17 +66,12 @@ from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox
|
||||
|
||||
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
|
||||
|
||||
APP = Path(__file__).resolve().parents[1]
|
||||
REPO = APP.parents[1]
|
||||
EVIDENCE = (
|
||||
REPO
|
||||
/ "dist"
|
||||
/ "OrisysStudio"
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
SOURCE_CLIP = EVIDENCE / "raw_0001.mp4"
|
||||
#: The one real input, tracked in the repository beside this test: a 33-frame box
|
||||
#: recording (2026-09-30, session 2, 1.9 s) copied out of ``dist/`` before that build
|
||||
#: output was next repackaged. See ``data/README.md`` for what it is and why it is here
|
||||
#: rather than generated.
|
||||
DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
|
||||
SOURCE_CLIP = DATA / "raw_0001.mp4"
|
||||
|
||||
|
||||
def _require_clip() -> Path:
|
||||
@@ -90,7 +86,8 @@ def _require_clip() -> Path:
|
||||
"""
|
||||
assert SOURCE_CLIP.is_file(), (
|
||||
"the bare-video test needs recorded box footage at "
|
||||
f"{SOURCE_CLIP}; copy it there or run the build that produces dist/"
|
||||
f"{SOURCE_CLIP}; it is tracked in the repository (apps/studio/tests/data/README.md), "
|
||||
"so restore it rather than skipping the test"
|
||||
)
|
||||
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
|
||||
return SOURCE_CLIP
|
||||
@@ -396,16 +393,25 @@ def test_a_bare_video_is_analysed_and_scrubbed_in_both_directions(
|
||||
"the arrow view is the video's raw picture"
|
||||
)
|
||||
|
||||
# ---- overlay: arrows on the captured frame, not on the black placeholder -
|
||||
# ---- overlay: a result whose captured base cannot be rebuilt is refused -----
|
||||
# The overlay views paint on the live-shaped picture (UV unroll / box stitch). A
|
||||
# stored result does not contain it — replay rebuilds it from the result's
|
||||
# provenance — and for a box result that rebuild fails on this footage (measured:
|
||||
# every frame; the SDK stitch raises inside `Sensor.get_img`). Since 425fa0d the
|
||||
# panel **refuses** instead of painting arrows on a resized fisheye, and the refusal
|
||||
# with its reason is the honest thing to assert here.
|
||||
#
|
||||
# It used to assert painted arrows over the captured frame. That claim was only ever
|
||||
# green because the footage it needed had been deleted from `dist/`; the box overlay
|
||||
# base is itself broken (tracked separately) — this test states what the operator
|
||||
# actually gets, not what an earlier build happened to draw.
|
||||
set_mode(window, ARROW_STITCHED)
|
||||
QApplication.processEvents()
|
||||
overlay = panel_bgr(window)
|
||||
assert painted(overlay, canvas) > 0, "the overlay painted no arrows"
|
||||
assert float(np.mean(overlay)) > 8.0, (
|
||||
"the overlay is still a near-black canvas; the captured frame was not under the arrows"
|
||||
assert window.arrow_panel._source_image is None, (
|
||||
"the overlay drew something without the captured frame under it"
|
||||
)
|
||||
assert not np.array_equal(overlay, middle_shot), (
|
||||
"the overlay view is the pure-colour canvas"
|
||||
assert window.arrow_panel.image_label.text() == t("panels.arrow_capture_unavailable"), (
|
||||
window.arrow_panel.image_label.text()
|
||||
)
|
||||
|
||||
# ---- the depth view, from the same stored frame -------------------------
|
||||
|
||||
@@ -14,7 +14,7 @@ button, is what tells stopped from paused.
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
from _studio_helpers import finish_run, running_window, show_window
|
||||
|
||||
|
||||
def test_the_transport_is_idle_with_no_session(make_window):
|
||||
|
||||
@@ -13,12 +13,13 @@ What is being defended, in the order the plan asked for it:
|
||||
5. **frames are addressed by id.** An id the recording does not hold is refused, never
|
||||
coerced into an index.
|
||||
|
||||
The fixtures use real footage — the 33-frame box recording under ``dist/`` and the
|
||||
shipped finger configuration — driven through the **finger** chain, which is what gives
|
||||
the result a validity mask and therefore a static candidate grid. The pass is O(5 s)
|
||||
and is shared per module. Where the SDK or the footage is absent the tests skip rather
|
||||
than fake a result: a synthetic result would not defend the id span (20…52), the
|
||||
keyframe identity or the sample round trip.
|
||||
The fixtures use real footage — the 33-frame box recording tracked beside this test
|
||||
(``data/box_short_20260930``) and the shipped finger configuration — driven through the
|
||||
**finger** chain, which is what gives the result a validity mask and therefore a static
|
||||
candidate grid. The pass is O(5 s) and is shared per module. Where the SDK is absent the
|
||||
tests skip rather than fake a result: a synthetic result would not defend the id span
|
||||
(20…52), the keyframe identity or the sample round trip. Where the *footage* is absent they
|
||||
fail, naming the file: a missing input is not an environment that should go green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -73,16 +74,13 @@ from orisys_studio.replay_analysis import (
|
||||
ResultPolicyError,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
STUDIO = REPO / "dist" / "OrisysStudio"
|
||||
RECORDING = (
|
||||
STUDIO
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
FINGER_CONFIG = STUDIO / "config" / "sensors" / "finger" / "sensor.json"
|
||||
FINGER_EXPORT = STUDIO / "config" / "sensors" / "finger" / "assets" / "30fps-0703"
|
||||
#: The tracked test input (``apps/studio/tests/data/README.md`` says where it came from).
|
||||
RECORDING = Path(__file__).resolve().parent / "data" / "box_short_20260930"
|
||||
#: The repository's own sensor configuration. These used to be read from the packaged copy
|
||||
#: under ``dist/``, which a build can delete — the same reason the footage moved here.
|
||||
CONFIG = Path(__file__).resolve().parents[1] / "config"
|
||||
FINGER_CONFIG = CONFIG / "sensors" / "finger" / "sensor.json"
|
||||
FINGER_EXPORT = CONFIG / "sensors" / "finger" / "assets" / "30fps-0703"
|
||||
|
||||
#: The evidence recording's own frame ids. Deliberately not 0…32: an implementation
|
||||
#: that used an index would pass every test written against a 0-based recording.
|
||||
@@ -91,13 +89,22 @@ LAST_FRAME_ID = 52
|
||||
|
||||
|
||||
def _require_inputs() -> None:
|
||||
"""The footage is committed, so its absence is a failure rather than a skip."""
|
||||
missing = [
|
||||
str(path)
|
||||
for path in (RECORDING / "frames.csv", RECORDING / "raw_0001.mp4", FINGER_CONFIG)
|
||||
for path in (
|
||||
RECORDING / "frames.csv",
|
||||
RECORDING / "raw_0001.mp4",
|
||||
FINGER_CONFIG,
|
||||
)
|
||||
if not path.exists()
|
||||
]
|
||||
if missing:
|
||||
pytest.skip("analysis inputs are not present: " + ", ".join(missing))
|
||||
assert not missing, (
|
||||
"analysis inputs are missing: "
|
||||
+ ", ".join(missing)
|
||||
+ ". The recording is committed (apps/studio/tests/data/README.md); restore it "
|
||||
"rather than skipping."
|
||||
)
|
||||
pytest.importorskip("orisys", reason="the SDK is needed to run the analysis pass")
|
||||
|
||||
|
||||
@@ -479,6 +486,11 @@ class _Host:
|
||||
if paint_2d:
|
||||
from orisys_app_shared.acquisition.image_backend import ImageFrame
|
||||
|
||||
# The fields the window's own sink puts on the frame it draws
|
||||
# (``app_window.py:2835-2851``). ``depth_valid``/``flow_valid`` are what
|
||||
# ``plan_image`` reads to decide whether the frame can be drawn as that
|
||||
# view, so a stub that leaves them out cannot answer "the depth view is
|
||||
# real" — which is exactly what the depth test below asks.
|
||||
self.frames.append(
|
||||
ImageFrame(
|
||||
getattr(packet, "processed_frame", None),
|
||||
@@ -487,6 +499,10 @@ class _Host:
|
||||
valid_mask=getattr(packet, "valid_mask", None),
|
||||
source=image_source or "replay",
|
||||
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
||||
flow_valid=getattr(packet, "flow_valid", None),
|
||||
flow_frame_id=getattr(packet, "flow_frame_id", None),
|
||||
depth_valid=getattr(packet, "depth_valid", None),
|
||||
depth_frame_id=getattr(packet, "depth_frame_id", None),
|
||||
arrow_policy=arrow_policy,
|
||||
result_available=True,
|
||||
)
|
||||
@@ -1652,6 +1668,8 @@ def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from orisys_app_shared.analysis.model import Provenance
|
||||
|
||||
class _Reader:
|
||||
complete = True
|
||||
key = "stitch"
|
||||
@@ -1659,6 +1677,14 @@ def test_a_stitch_grid_is_not_painted_onto_the_camera_frame():
|
||||
frame_ids = np.array([1], dtype=np.int64)
|
||||
times = np.array([0.0])
|
||||
keyframes: dict = {}
|
||||
#: A real reader always carries one (``AnalysisReader.__init__`` builds it from
|
||||
#: the result's metadata) and ``AnalysisSession`` reads it: *where* the samples
|
||||
#: are travels from ``provenance.policy``, and the overlay base opens from the
|
||||
#: rest. ``829d2c1`` added those reads; this stub predated them and the default
|
||||
#: ``Provenance()`` is the honest stand-in — an empty policy, so the session
|
||||
#: falls back to the display policy exactly as it does for a result that stored
|
||||
#: no grid.
|
||||
provenance = Provenance()
|
||||
|
||||
def mask(self):
|
||||
return None
|
||||
|
||||
@@ -18,7 +18,7 @@ chain — and assert on the picture the panel actually shows, not on a state a h
|
||||
built.
|
||||
|
||||
The recording is copied before use: a pass writes its result into the recording's own
|
||||
directory, and nothing may write into ``dist/``.
|
||||
directory, and the tracked copy the tests read must never be written to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,11 +34,11 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
DEPTH_ARROWS,
|
||||
DERIVED_MODES,
|
||||
RECORDED,
|
||||
plan_image,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
from orisys_app_shared.scene3d.visual_policy import (
|
||||
select_flow_samples,
|
||||
@@ -46,14 +46,8 @@ from orisys_app_shared.scene3d.visual_policy import (
|
||||
from orisys_studio.replay import MODE_RECORDING
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
STUDIO = REPO / "dist" / "OrisysStudio"
|
||||
SOURCE_RECORDING = (
|
||||
STUDIO
|
||||
/ "outputs"
|
||||
/ "recordings"
|
||||
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
|
||||
)
|
||||
#: The tracking-visible test input (``data/README.md`` explains where it came from).
|
||||
DATA = Path(__file__).resolve().parent / "data" / "box_short_20260930"
|
||||
#: What makes a directory a recording for the application, and all a replay reads.
|
||||
RECORDING_FILES = (
|
||||
"frames.csv",
|
||||
@@ -61,29 +55,41 @@ RECORDING_FILES = (
|
||||
"events.csv",
|
||||
"session.json",
|
||||
"raw_0001.mp4",
|
||||
"preroll_0001.mp4",
|
||||
)
|
||||
#: Written by a recording and read by nothing a replay needs: ``preroll_segment()``
|
||||
#: answers ``None`` when the file is absent, so it is copied when it is there and never
|
||||
#: required. It stays out of the repository because it is a third of the data's size.
|
||||
OPTIONAL_FILES = ("preroll_0001.mp4",)
|
||||
|
||||
|
||||
def _require_recording() -> None:
|
||||
"""The recording must be here, and its absence is a failure, not a skip.
|
||||
|
||||
The old version skipped: the tests went green on a machine where the footage had
|
||||
been repackaged away, which is exactly how eleven failing tests went unnoticed.
|
||||
"""
|
||||
missing = [
|
||||
str(DATA / name) for name in RECORDING_FILES if not (DATA / name).is_file()
|
||||
]
|
||||
assert not missing, (
|
||||
"the replay wiring tests need the tracked box test recording at "
|
||||
f"{DATA}; missing: {', '.join(missing)}. It is committed "
|
||||
"(apps/studio/tests/data/README.md); restore it rather than skipping."
|
||||
)
|
||||
|
||||
|
||||
def _require_inputs() -> None:
|
||||
missing = [
|
||||
str(SOURCE_RECORDING / name)
|
||||
for name in ("frames.csv", "raw_0001.mp4", "session.json")
|
||||
if not (SOURCE_RECORDING / name).exists()
|
||||
]
|
||||
if missing:
|
||||
pytest.skip(
|
||||
"the replay wiring tests need the evidence recording: " + ", ".join(missing)
|
||||
)
|
||||
_require_recording()
|
||||
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
|
||||
|
||||
|
||||
def _copy_recording(destination: Path) -> Path:
|
||||
"""The evidence recording, copied: nothing may write into ``dist/``."""
|
||||
"""The test recording, copied: nothing may write into the tracked data."""
|
||||
_require_recording()
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for name in RECORDING_FILES:
|
||||
source = SOURCE_RECORDING / name
|
||||
if source.exists():
|
||||
for name in RECORDING_FILES + OPTIONAL_FILES:
|
||||
source = DATA / name
|
||||
if source.is_file():
|
||||
shutil.copy2(source, destination / name)
|
||||
return destination
|
||||
|
||||
@@ -303,11 +309,16 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
|
||||
"a pass ran over a recording that already had a result"
|
||||
)
|
||||
|
||||
# ``depth_arrows`` joined the derived views in ``091d0fd`` ("二维增加深度叠箭头"), which
|
||||
# updated ``ui_check`` and the live-view tests but not this one: it was skipping on the
|
||||
# missing footage at the time, so nothing caught it. The claim is unchanged — the
|
||||
# recording's own picture stays one step away from *every* derived view.
|
||||
assert window.arrow_panel.view_modes() == (
|
||||
RECORDED,
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
DEPTH_ARROWS,
|
||||
), "the recording's own picture must stay one step away from the derived views"
|
||||
assert window.arrow_panel.view_mode() in DERIVED_MODES, (
|
||||
"the operator was left on the camera picture although a result is served"
|
||||
@@ -343,14 +354,22 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
|
||||
"the arrow view is the recording's raw picture"
|
||||
)
|
||||
|
||||
# The depth view is the stored map, drawn through the window's own backend. This
|
||||
# result's map is exactly zero on every frame it holds — the box chain's depth is
|
||||
# flat on this footage — and it is still a stored array of the result's own shape,
|
||||
# so the pixels are checked against it rather than against a flat placeholder.
|
||||
# The depth view is the stored map, drawn through the window's own backend. The
|
||||
# map is this result's own array of the result's own shape, so the pixels are checked
|
||||
# against it rather than against a placeholder.
|
||||
#
|
||||
# It is **not** all-zero, which is what this asserted when the recording was first
|
||||
# played (a "flat depth" note): measured on the restored footage with the shipped box
|
||||
# config, the stored map has 8926 non-zero cells of 365², i.e. the chain does return
|
||||
# a depth field here. Flatness was a property of an earlier run's result, not a
|
||||
# product promise; the claim this block exists for — the depth view's pixels are
|
||||
# ``depth_map_to_bgr`` of the stored frame — is asserted below and is unchanged.
|
||||
# The colour-scale caption names the range only.
|
||||
_set_mode(window, DEPTH)
|
||||
target = active_frame
|
||||
assert target.depth is not None and target.depth_nonzero == 0
|
||||
assert target.depth is not None and target.depth_nonzero > 0, (
|
||||
f"the stored depth map holds no value at all: {target.depth_nonzero} non-zero cells"
|
||||
)
|
||||
QApplication.processEvents()
|
||||
drawn = captured[-1]
|
||||
assert drawn.frame_id == target.frame_id
|
||||
|
||||
@@ -49,13 +49,13 @@ from orisys_studio.scene3d.asset_source import (
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
finish_run as _finish,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
running_window as _running_window,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
show_window as _show,
|
||||
)
|
||||
|
||||
@@ -1131,10 +1131,14 @@ def test_the_replay_view_shows_the_pane_and_says_where_the_field_comes_from(
|
||||
assert "回放" in label or "Replay" in label, label
|
||||
|
||||
# A running pass means the pixels are recomputed, and the label must say so.
|
||||
# Compared through the locale key rather than a word from it: ``c0a10e6`` reworded
|
||||
# this line ("重算" → "分析结果:非录制时的测量值"), and the claim being defended is
|
||||
# "*which* source the pane says it is showing", not the phrasing.
|
||||
window._recompute_running = True
|
||||
window._apply_panel_visibility()
|
||||
label = view.panel.source_label.text()
|
||||
assert "重算" in label or "Recomputed" in label, label
|
||||
assert view.panel.source_label.text() == t("scene3d.source_recompute"), (
|
||||
view.panel.source_label.text()
|
||||
)
|
||||
|
||||
# Stopping the pass must clear the picture, not leave the last frame up.
|
||||
window._recompute_running = False
|
||||
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import ui_check
|
||||
from orisys_app_shared.i18n import t
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
from _studio_helpers import finish_run, running_window, show_window
|
||||
|
||||
|
||||
def test_the_group_is_exactly_play_pause_stop_reset(make_window):
|
||||
|
||||
@@ -30,7 +30,7 @@ import ui_check
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_studio.ui import elements
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from tests.conftest import finish_run, show_window
|
||||
from _studio_helpers import finish_run, show_window
|
||||
|
||||
VIEWS = ("live", "replay", "calibration")
|
||||
SIZE = (1440, 880)
|
||||
|
||||
@@ -7,16 +7,16 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
import pytest
|
||||
import ui_check
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
finish_run as _finish,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
running_window as _running_window,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
show_window as _show,
|
||||
)
|
||||
from tests.conftest import (
|
||||
from _studio_helpers import (
|
||||
visible_descendants,
|
||||
)
|
||||
|
||||
@@ -193,13 +193,27 @@ def test_status_chip_tracks_phase(make_window):
|
||||
|
||||
|
||||
def test_run_tooltip_explains_disabled_reason(make_window):
|
||||
"""A video source never starts a live run, and the rail says why.
|
||||
|
||||
The button used to become enabled when a video file was chosen here; the live view
|
||||
is a camera only (``test_live_run_refuses_a_video_source`` pins the same rule from
|
||||
the other side), so choosing a file leaves Run unavailable and the reason names the
|
||||
camera instead of a missing file.
|
||||
"""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(source_mode="video", video_path=""), (1400, 880))
|
||||
assert not window.toolbar.btn_run.isEnabled()
|
||||
assert window.toolbar.btn_run.toolTip()
|
||||
|
||||
window.toolbar.set_video_path("C:/tmp/demo.mp4")
|
||||
window._update_run_enabled()
|
||||
assert window.toolbar.btn_run.isEnabled()
|
||||
reason = t("toolbar.run_video_in_replay")
|
||||
assert not window.toolbar.btn_run.isEnabled(), (
|
||||
"a video source still starts a live run"
|
||||
)
|
||||
assert window.toolbar.btn_run.toolTip() == reason, window.toolbar.btn_run.toolTip()
|
||||
assert window.toolbar.run_hint.text() == reason, window.toolbar.run_hint.text()
|
||||
|
||||
|
||||
def test_toolbar_run_button_is_labelled(make_window):
|
||||
@@ -1501,7 +1515,18 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
|
||||
assert dialog.calib_title.text() == t("sensor.calib_section")
|
||||
assert dialog.version_label.text() and dialog.axes_label.text()
|
||||
assert dialog.export_label.text() == t("sensor.export_none")
|
||||
assert not dialog.btn_apply.isEnabled(), "applying with no export"
|
||||
# 应用 is the *live calibration* action, not "apply the last export" (``3d2fb0c``
|
||||
# moved it here): it is offered with no export because there is always a choice to
|
||||
# apply — the profile's declared file, a browsed one, or raw force — and with no
|
||||
# session running it only remembers that choice (``sensor_dialog.py:324`` enables it
|
||||
# unconditionally; ``apply_live_calibration_choice`` reports a problem instead of
|
||||
# raising, so an offered button cannot fail).
|
||||
assert dialog.btn_apply.isEnabled(), "the live calibration cannot be applied"
|
||||
assert dialog.btn_apply.text() == t("cal.apply")
|
||||
assert dialog.btn_apply.toolTip() == t("sensor.live_calibration_apply_tip")
|
||||
assert dialog.calibration_combo.currentData() in ("default", "none"), (
|
||||
dialog.calibration_combo.currentData()
|
||||
)
|
||||
|
||||
# Changing the product in the dialog reaches the window and the rail.
|
||||
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger"))
|
||||
@@ -1659,11 +1684,17 @@ def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
|
||||
|
||||
|
||||
def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
||||
"""One layout for both views; the only difference is the source slot.
|
||||
"""The same panes and the same transport as the live view; two stated differences.
|
||||
|
||||
The replay used to carry its own bar (a progress slider plus a recording
|
||||
picker). It is gone: the chart's cursor is the playhead, its header carries
|
||||
the transport, and the rail shows the video instead of the camera.
|
||||
|
||||
What differs is deliberate (``321e52f``: "keep each preset's panel priority"):
|
||||
the replay gives the **charts** the room, so the 2D panel is narrower than in the
|
||||
live view and the charts are wider. This used to assert the splitter widths were
|
||||
*equal*, which was true before that change and is the opposite of what the
|
||||
layout now promises.
|
||||
"""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
@@ -1673,13 +1704,23 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
||||
|
||||
QApplication.processEvents()
|
||||
live_sizes = tuple(window.content_splitter.sizes())
|
||||
# Slot 1 is the 2D panel and slot 2 the charts (0 and 3 are the mesh and calibration
|
||||
# panels, both hidden here, hence their zero width).
|
||||
assert live_sizes[1] > 0 and live_sizes[2] > 0, live_sizes
|
||||
|
||||
window.brand_bar.btn_playback.click()
|
||||
QApplication.processEvents()
|
||||
assert not hasattr(window, "replay_bar"), "the replay still has a separate bar"
|
||||
assert tuple(window.content_splitter.sizes()) == live_sizes, (
|
||||
"the replay view is not laid out like the live view"
|
||||
replay_sizes = tuple(window.content_splitter.sizes())
|
||||
assert replay_sizes[1] < live_sizes[1], (
|
||||
f"the replay 2D panel is not narrower than the live one: {replay_sizes} vs {live_sizes}"
|
||||
)
|
||||
assert replay_sizes[2] > live_sizes[2], (
|
||||
f"the replay charts did not take that room: {replay_sizes} vs {live_sizes}"
|
||||
)
|
||||
# The priority behind those numbers is ``view_layout.py``'s per-preset stretch
|
||||
# factors (arrow 4, charts 5 in replay). The splitter exposes no getter for them in
|
||||
# PySide6, so the widths above are what this test can read.
|
||||
assert window.toolbar.btn_replay_video.isVisible(), "no video picker while replaying"
|
||||
assert window.toolbar.btn_replay_video.text() == t("toolbar.choose_video")
|
||||
assert window.toolbar.btn_replay_video.toolTip() == t("toolbar.choose_video_tip")
|
||||
@@ -2949,6 +2990,14 @@ def test_the_rail_puts_result_identity_before_a_long_video_name(
|
||||
|
||||
|
||||
def test_old_result_depth_is_not_sent_to_the_new_sensor_scene(make_window, monkeypatch):
|
||||
"""A result from the previous sensor must not reach the new sensor's 3D scene.
|
||||
|
||||
Driven through the window's public packet entry (``show_analysis_packet``, which
|
||||
``cf49b08``/``28e955a`` made the replay path; the private ``_show_analysis_frame``
|
||||
it replaced no longer exists). The claim is unchanged: a Finger window refuses a
|
||||
packet whose result says it came from a Box, and accepts the same packet once the
|
||||
result names the sensor on screen.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
window = make_window(sensor_type="finger")
|
||||
@@ -2968,10 +3017,10 @@ def test_old_result_depth_is_not_sent_to_the_new_sensor_scene(make_window, monke
|
||||
monkeypatch.setattr(window, "_connect_analysis_display", lambda _panel: True)
|
||||
monkeypatch.setattr(window, "_tick_analysis_3d", lambda: None)
|
||||
try:
|
||||
window._show_analysis_frame("old box packet")
|
||||
assert submitted == []
|
||||
window.show_analysis_packet("old box packet")
|
||||
assert submitted == [], "an old sensor's depth reached the new sensor's scene"
|
||||
session.reader.provenance.sensor_type = "finger"
|
||||
window._show_analysis_frame("finger packet")
|
||||
window.show_analysis_packet("finger packet")
|
||||
assert submitted == ["finger packet"]
|
||||
finally:
|
||||
panel._view = real_view
|
||||
|
||||
@@ -1711,8 +1711,9 @@ def main() -> int:
|
||||
window.force_plots.mode() == "follow",
|
||||
"the replay view browses before a recording is open",
|
||||
)
|
||||
# The replay view is the live arrangement, measured rather than assumed: the
|
||||
# splitter hands out its handles' width, so compare like with like.
|
||||
# The replay view shows the live view's panes, and its preset gives the *charts* the
|
||||
# room (321e52f: "keep each preset's panel priority"), so the splitter is measured
|
||||
# rather than assumed: slot 1 is the 2D panel, slot 2 the charts.
|
||||
window.brand_bar.btn_live.click()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
@@ -1722,8 +1723,13 @@ def main() -> int:
|
||||
app.processEvents()
|
||||
replay_sizes = tuple(window.content_splitter.sizes())
|
||||
check(
|
||||
replay_sizes == live_sizes,
|
||||
f"the replay view is not the live arrangement: {replay_sizes} vs {live_sizes}",
|
||||
live_sizes[1] > 0 and live_sizes[2] > 0,
|
||||
f"the live view lost a pane: {live_sizes}",
|
||||
)
|
||||
check(
|
||||
replay_sizes[1] < live_sizes[1] and replay_sizes[2] > live_sizes[2],
|
||||
"the replay view does not give the charts more room than the live view: "
|
||||
f"{replay_sizes} vs {live_sizes}",
|
||||
)
|
||||
check(
|
||||
not hasattr(window, "replay_bar"),
|
||||
@@ -2368,13 +2374,23 @@ def main() -> int:
|
||||
f"a failed start left the recorder in {fail_window.recorder.state!r}",
|
||||
)
|
||||
|
||||
# ...and the operator can simply try again.
|
||||
# ...and the operator can simply try again. ``start()`` returns once the directory
|
||||
# exists; the recorder itself moves to ``recording`` on its own writer thread, so
|
||||
# reading the flag back immediately is a race (measured: one full run in three
|
||||
# reported "refused to retry" although the retry had been accepted). The claim is
|
||||
# "retrying works", not "it worked inside one event loop", so wait for it — bounded,
|
||||
# like the write-error poll below.
|
||||
_app_module.recordings_dir = lambda: scratch_fail
|
||||
fail_window.toolbar.btn_record.setChecked(True)
|
||||
app.processEvents()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not fail_window.recorder.is_recording and time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.01)
|
||||
check(
|
||||
fail_window.recorder.is_recording,
|
||||
"the recorder refused to retry after a failed directory",
|
||||
"the recorder refused to retry after a failed directory "
|
||||
f"(state {fail_window.recorder.state!r} after 2 s)",
|
||||
)
|
||||
|
||||
# A writer that keeps failing must fail the recording, and the window must
|
||||
|
||||
Reference in New Issue
Block a user