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:
ykcheungab
2026-10-18 21:17:03 +08:00
parent 0998009ec3
commit c37efd7a44
9 changed files with 648 additions and 260 deletions
@@ -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
+12 -5
View File
@@ -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
+58
View File
@@ -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