Lint the commits being pushed and scope overlay failures per sensor
The pre-push gate linted only the dirty tree, so the commits about to be pushed went unchecked; and every failed overlay projection was sticky, which cost a finger result its overlay over one bad frame while box results were retried nowhere. gate.ps1 now takes -LintBase (default origin/main, exit 2 on an unknown ref), the projector declares whether its own failures are permanent, and serve_stored_view decides before it sets the view. Verified with gate.ps1 (PASS: 569 studio + 485 shared tests, 632 ui_check checks) and the two round-2 red samples kept in scratch.
This commit is contained in:
@@ -389,8 +389,13 @@ class AnalysisSession:
|
||||
"""The live-shaped processed image for this camera frame, or ``None``.
|
||||
|
||||
Opens a session-scoped projector from the result's provenance on first use.
|
||||
Failure is sticky for the session: overlay views then report capture
|
||||
unavailable rather than falling back to a resized fisheye.
|
||||
**How long a failure lasts is the projector's answer, not this method's guess:**
|
||||
a projector that declares its projection failures permanent (a box stitch that
|
||||
cannot rebuild the lenslet grid on this footage) is recorded as unavailable and
|
||||
not asked again; one that declares them transient (a finger unroll that lost a
|
||||
single frame) skips that frame and is tried again on the next. The alternative —
|
||||
making every failure sticky — cost a finger result its whole overlay view over
|
||||
one bad frame (reported as R2 in docs/test-repair-rework.zh.md).
|
||||
"""
|
||||
if self._overlay_failed:
|
||||
return None
|
||||
@@ -401,16 +406,11 @@ class AnalysisSession:
|
||||
self.reader.provenance
|
||||
)
|
||||
if self._overlay is None:
|
||||
# Nothing can be opened for this result at all: permanent either way.
|
||||
self._overlay_failed = True
|
||||
return None
|
||||
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.
|
||||
if projected is None and self._overlay.projection_failures_are_permanent:
|
||||
self._overlay_failed = True
|
||||
return projected
|
||||
|
||||
@@ -420,12 +420,12 @@ class AnalysisSession:
|
||||
|
||||
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.
|
||||
contain: it is rebuilt from the result's provenance on first use. The answer is
|
||||
only known after an attempt, and it is sticky **only for a failure the projector
|
||||
declares permanent** — a box result whose stitch cannot rebuild, or a projector
|
||||
that would not open at all. A finger unroll that loses one frame is not sticky
|
||||
(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)
|
||||
|
||||
|
||||
@@ -1034,13 +1034,21 @@ class ReplayController(QObject):
|
||||
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.
|
||||
**But only a view this result can actually draw, decided before it is set.** 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.
|
||||
|
||||
The probe runs *before* the view is set (see :meth:`_overlay_base_ready`) so the
|
||||
panel is set once and drawn once: setting the overlay view first and switching
|
||||
after the first render flashed the refusal on screen during adoption (R3 of
|
||||
docs/test-repair-rework.zh.md). The base it asks for is the one the render that
|
||||
follows will ask for, and the projector caches by frame id, so deciding costs no
|
||||
second projection.
|
||||
"""
|
||||
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
|
||||
if current in DERIVED_MODES:
|
||||
@@ -1048,16 +1056,29 @@ class ReplayController(QObject):
|
||||
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.
|
||||
overlay_failed = bool(getattr(session, "overlay_unavailable", False))
|
||||
self._image_panel.set_view_mode(ARROW_PURE if overlay_failed else ARROW_STITCHED)
|
||||
drawable = True if session is None else self._overlay_base_ready(session)
|
||||
self._image_panel.set_view_mode(ARROW_STITCHED if drawable else ARROW_PURE)
|
||||
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 _overlay_base_ready(self, session) -> bool:
|
||||
"""Whether ``render_frame`` can paint this result's overlay base, without drawing.
|
||||
|
||||
Asks the session for the same base, for the same frame, that the render will ask
|
||||
for. ``True`` when there is no frame to judge: an unknown answer must not invent a
|
||||
fallback view the operator did not ask for. A session that has already declared the
|
||||
overlay unavailable is answered without another projection attempt.
|
||||
"""
|
||||
if bool(getattr(session, "overlay_unavailable", False)):
|
||||
return False
|
||||
if self.analysis_active():
|
||||
frame = self._engine.analysed_frame()
|
||||
else:
|
||||
frame = session.frame_at(self._engine.position())
|
||||
if frame is None:
|
||||
return True
|
||||
return self._processed_base_for(frame, session) is not None
|
||||
|
||||
def _display_policy(self) -> Any:
|
||||
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
||||
|
||||
|
||||
@@ -11,15 +11,22 @@ generated pixels, and a synthetic 896² clip sent the chain into minutes of work
|
||||
| | |
|
||||
|---|---|
|
||||
| Sensor | `box` (shipped bundle) |
|
||||
| Source | `dist/OrisysStudio/outputs/recordings/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011`, recorded 2026-09-30, `session_id` 2 |
|
||||
| Source | [`.scratch/result-visible/recording/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011/`](../../../../.scratch/result-visible/recording/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011/) — recorded 2026-09-30, `session_id` 2. The directory name is the recording's own identity, so it matches the `dist/` path below. |
|
||||
| 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.
|
||||
`dist/` is build output and is not tracked, so the copy under `dist/OrisysStudio/outputs/recordings/` 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 `dist/` original is **gone**, so the files here cannot be compared against it byte for byte. They are byte-identical to the copy preserved under `.scratch/result-visible/` (the source named above), which is the strongest provenance available. The hashes are recorded here so a future copy can be checked against this one instead of argued about:
|
||||
|
||||
| File | SHA256 |
|
||||
|---|---|
|
||||
| `raw_0001.mp4` | `907867d0fe82966dbaacf97a8bb0a8d5647de25458ad5fd4640535d83d417207` |
|
||||
| `frames.csv` | `fab25e6780908aef8f7616504b43cf34062ab7ca3ba317b6ff4c0631969e0601` |
|
||||
| `forces.csv` | `972d3c951ff0be847a6a224b53339401f115cd3728039f1acb24a33bf215ecb1` |
|
||||
| `events.csv` | `6fe7121b1c621ebed472ee42376babfd76a32a364ab9f11660da077fb365a946` |
|
||||
| `session.json` | `f3c9afc4502180d2a88cd260cb70f1f4313e203f2c3c8441704de6ac53a1db4c` |
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""How long an overlay-base failure lasts: one frame, or the whole session.
|
||||
|
||||
R2 of ``docs/test-repair-rework.zh.md``. The second round made every failed projection
|
||||
sticky, so one bad finger frame cost that result its overlay view for the session — a
|
||||
behaviour change beyond the box case it was written for. The scope is now the
|
||||
projector's own declaration (``overlay_base.OverlayBaseProjector``), never a guess made
|
||||
from a ``None`` or from an exception's text:
|
||||
|
||||
* a **finger** unroll that loses a frame is transient — the next frame is tried and the
|
||||
session is *not* marked unavailable;
|
||||
* a **box** stitch that cannot rebuild the lenslet grid on this footage is permanent —
|
||||
the session records it and stops asking, so scrubbing does not rebuild the same
|
||||
failure on every cursor move;
|
||||
* a projector that cannot be **opened** at all is permanent, because no frame can work.
|
||||
|
||||
No SDK and no recording are involved: the projector is given a stand-in ``project``, and
|
||||
the session is handed that projector directly, so these run on any machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from orisys_app_shared.analysis.model import Provenance
|
||||
from orisys_app_shared.analysis.overlay_base import (
|
||||
OverlayBaseProjector,
|
||||
projection_failures_are_permanent,
|
||||
)
|
||||
from orisys_studio.replay_analysis import AnalysisSession
|
||||
|
||||
FRAME = np.zeros((6, 6, 3), dtype=np.uint8)
|
||||
BASE = np.full((6, 6, 3), 7, dtype=np.uint8)
|
||||
|
||||
|
||||
class _Reader:
|
||||
"""The smallest reader ``AnalysisSession`` accepts, with no result behind it."""
|
||||
|
||||
complete = True
|
||||
key = "overlay-scope"
|
||||
directory = Path(".")
|
||||
frame_ids = np.array([1, 2], dtype=np.int64)
|
||||
times = np.array([0.0, 0.1])
|
||||
keyframes: dict = {}
|
||||
provenance = Provenance()
|
||||
|
||||
def mask(self):
|
||||
return None
|
||||
|
||||
def frame(self, frame_id):
|
||||
return SimpleNamespace(depth=np.zeros((6, 6), dtype=np.float32))
|
||||
|
||||
def grid(self, grid_id):
|
||||
raise AssertionError("no grid in this stub")
|
||||
|
||||
|
||||
def _session(projector) -> AnalysisSession:
|
||||
"""A session whose projector is supplied directly (its provenance is not used)."""
|
||||
session = AnalysisSession(_Reader())
|
||||
session._overlay = projector
|
||||
return session
|
||||
|
||||
|
||||
def test_one_bad_finger_frame_does_not_cost_the_session_its_overlay():
|
||||
"""A finger unroll that fails on one frame is retried on the next."""
|
||||
answers = [None, BASE]
|
||||
projector = OverlayBaseProjector(
|
||||
sensor_type="finger",
|
||||
project=lambda image: answers.pop(0),
|
||||
projection_failures_are_permanent=False,
|
||||
)
|
||||
session = _session(projector)
|
||||
|
||||
assert session.overlay_base(FRAME, frame_id=1) is None
|
||||
assert session.overlay_unavailable is False, "one frame made the whole result unavailable"
|
||||
|
||||
base = session.overlay_base(FRAME, frame_id=2)
|
||||
assert base is not None, "the second frame was never projected"
|
||||
assert np.array_equal(base, BASE)
|
||||
assert session.overlay_unavailable is False
|
||||
|
||||
|
||||
def test_a_finger_projection_that_raises_is_also_only_that_frame():
|
||||
"""The exception text is not consulted: a raising finger projection is transient."""
|
||||
answers = [RuntimeError("unroll miss"), BASE]
|
||||
|
||||
def project(image):
|
||||
answer = answers.pop(0)
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return answer
|
||||
|
||||
session = _session(
|
||||
OverlayBaseProjector(
|
||||
sensor_type="finger",
|
||||
project=project,
|
||||
projection_failures_are_permanent=False,
|
||||
)
|
||||
)
|
||||
assert session.overlay_base(FRAME, frame_id=1) is None
|
||||
assert session.overlay_unavailable is False
|
||||
assert session.overlay_base(FRAME, frame_id=2) is not None
|
||||
|
||||
|
||||
def test_a_box_projection_that_fails_is_permanent_and_is_not_retried():
|
||||
"""A box stitch that fails will fail again; the session stops asking."""
|
||||
calls: list[int] = []
|
||||
|
||||
def project(image):
|
||||
calls.append(1)
|
||||
return None
|
||||
|
||||
session = _session(OverlayBaseProjector(sensor_type="box", project=project))
|
||||
|
||||
assert session.overlay_base(FRAME, frame_id=1) is None
|
||||
assert session.overlay_unavailable is True
|
||||
assert session.overlay_base(FRAME, frame_id=2) is None
|
||||
assert len(calls) == 1, "a permanent failure was retried on the next frame"
|
||||
|
||||
|
||||
def test_a_projector_that_cannot_open_is_permanent(monkeypatch):
|
||||
"""No projector at all is the other permanent case: no frame could ever work."""
|
||||
calls: list[int] = []
|
||||
|
||||
@classmethod
|
||||
def _cannot_open(cls, provenance):
|
||||
calls.append(1)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(OverlayBaseProjector, "open_from_provenance", _cannot_open)
|
||||
session = AnalysisSession(_Reader())
|
||||
|
||||
assert session.overlay_base(FRAME, frame_id=1) is None
|
||||
assert session.overlay_unavailable is True
|
||||
assert session.overlay_base(FRAME, frame_id=2) is None
|
||||
assert len(calls) == 1, "opening was retried after it had already failed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sensor_type", "permanent"),
|
||||
(("finger", False), ("box", True)),
|
||||
)
|
||||
def test_the_sensor_decides_which_kind_of_failure_it_has(sensor_type, permanent):
|
||||
"""The policy lives with the sensor's opener, not in each caller's judgement."""
|
||||
assert projection_failures_are_permanent(sensor_type) is permanent
|
||||
@@ -772,3 +772,61 @@ def test_a_bare_video_with_a_declined_prompt_promises_no_pass(
|
||||
)
|
||||
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text()
|
||||
assert plan_image(captured[-1]).note_key == "panels.replay_analysis_failed"
|
||||
|
||||
|
||||
def test_adopting_a_box_result_never_flashes_the_overlay_refusal(
|
||||
window, stored_recording, monkeypatch
|
||||
):
|
||||
"""R3: pick the drawable view once, draw once, and never show the refusal on the way.
|
||||
|
||||
The box result has no stored unroll map and its stitch rebuild fails on this footage,
|
||||
so the overlay base cannot be built. The pre-rework order — set the overlay view,
|
||||
render, discover the failure, switch, render again — put
|
||||
``panels.arrow_capture_unavailable`` on screen while adopting the result, and drew the
|
||||
same frame twice. The decision is now made before the view is set, so the panel is set
|
||||
once and rendered once, and the refusal is still what the operator gets when they ask
|
||||
for the overlay themselves.
|
||||
"""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
controller = window.replay_ctl
|
||||
refusal = t("panels.arrow_capture_unavailable")
|
||||
seen: list[str] = []
|
||||
renders: list[int] = []
|
||||
inside = {"serve": False}
|
||||
|
||||
original_render = controller.render_frame
|
||||
|
||||
def render_spy():
|
||||
if inside["serve"]:
|
||||
renders.append(1)
|
||||
original_render()
|
||||
seen.append(window.arrow_panel.image_label.text())
|
||||
else:
|
||||
original_render()
|
||||
|
||||
original_serve = controller.serve_stored_view
|
||||
|
||||
def serve_spy():
|
||||
inside["serve"] = True
|
||||
try:
|
||||
return original_serve()
|
||||
finally:
|
||||
inside["serve"] = False
|
||||
|
||||
monkeypatch.setattr(controller, "render_frame", render_spy)
|
||||
monkeypatch.setattr(controller, "serve_stored_view", serve_spy)
|
||||
|
||||
_open(window, stored_recording, monkeypatch)
|
||||
|
||||
assert inside["serve"] is False, "adoption never reached the view it serves"
|
||||
assert len(renders) == 1, f"adoption rendered the frame {len(renders)} times, not once"
|
||||
assert refusal not in seen, "the overlay refusal was shown while adopting the result"
|
||||
assert controller._image_panel.view_mode() == ARROW_PURE, (
|
||||
"a result whose overlay cannot be drawn was adopted on the overlay view"
|
||||
)
|
||||
|
||||
# The overlay is still on offer, and choosing it still says why it cannot be drawn.
|
||||
controller._image_panel.set_view_mode(ARROW_STITCHED)
|
||||
controller.render_frame()
|
||||
assert window.arrow_panel.image_label.text() == refusal
|
||||
|
||||
Reference in New Issue
Block a user