Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions probeflow/gui/viewer/image_viewer_display_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ def _on_set_zero_plane_mode_toggled(self, checked: bool):

def _on_set_zero_pick(self, frac_x: float, frac_y: float):
"""Handle image clicks while manual zero-plane mode is active."""
# The user is clicking on the *displayed* array; the controller maps
# the click fraction into that frame and stamps the geometric-op
# count so replay anchors the plane on the clicked features.
arr = self._display_arr if self._display_arr is not None else self._raw_arr
rerender, msg = self._zero_ctrl.on_canvas_pick(
frac_x, frac_y,
self._raw_arr,
arr,
self._processing,
self._set_zero_plane_btn.isChecked(),
)
Expand All @@ -36,7 +40,8 @@ def _on_set_zero_pick(self, frac_x: float, frac_y: float):
self._refresh_processing_display()

def _refresh_zero_markers(self):
self._zero_ctrl.refresh_markers(self._raw_arr, self._processing)
arr = self._display_arr if self._display_arr is not None else self._raw_arr
self._zero_ctrl.refresh_markers(arr, self._processing)

def _on_clear_set_zero(self):
if self._set_zero_plane_btn.isChecked():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def _on_apply_processing(self):
"set_zero_xy",
"set_zero_plane_points",
"set_zero_patch",
"set_zero_after_geometric_ops",
"periodic_notches",
"periodic_notch_radius",
"geometric_ops",
Expand Down
34 changes: 26 additions & 8 deletions probeflow/gui/viewer/set_zero_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,30 @@ def on_canvas_pick(
self,
frac_x: float,
frac_y: float,
raw_arr: np.ndarray | None,
display_arr: np.ndarray | None,
processing: dict,
mode_btn_checked: bool,
) -> tuple[bool, str]:
"""Handle an image click while zero-plane mode is active.

``display_arr`` is the array the user is actually clicking on (the
displayed, possibly processed one): the click fraction is converted
to pixel coordinates in *that* frame, and the completed pick set is
stamped with the current geometric-op count so replay applies the
zero plane in the same frame (2026-06-12 workflow review: mapping
fractions onto the raw shape anchored the plane at the mirrored /
wrong feature once a flip or rotation was in the pipeline, while the
markers — drawn from the same fractions — still showed the clicked
spots).

Returns ``(trigger_rerender, status_message)``. When
``trigger_rerender`` is ``True`` the caller should call
``_refresh_processing_display()`` and un-toggle the mode button.
"""
if raw_arr is None:
if display_arr is None:
return False, ""

Ny, Nx = raw_arr.shape
Ny, Nx = display_arr.shape
x_px = max(0, min(int(round(frac_x * (Nx - 1))), Nx - 1))
y_px = max(0, min(int(round(frac_y * (Ny - 1))), Ny - 1))

Expand All @@ -84,7 +94,7 @@ def on_canvas_pick(
self._markers_hidden = False
self._points_px.append((x_px, y_px))
n = len(self._points_px)
self.refresh_markers(raw_arr, processing)
self.refresh_markers(display_arr, processing)

if n < 3:
return False, (
Expand All @@ -94,18 +104,26 @@ def on_canvas_pick(

processing["set_zero_plane_points"] = self._points_px[:3]
processing["set_zero_patch"] = 1
# Frame stamp: replay re-inserts the set-zero step after this many
# geometric ops, the frame the pixel coordinates were picked in.
processing["set_zero_after_geometric_ops"] = len(
processing.get("geometric_ops") or ())
processing.pop("set_zero_xy", None)
return True, "Zero plane set from 3 reference points."

# ── Marker refresh ────────────────────────────────────────────────────────

def refresh_markers(self, raw_arr: np.ndarray | None, processing: dict) -> None:
"""Push the current pick state into the canvas as zero markers."""
if raw_arr is None or self._markers_hidden:
def refresh_markers(self, display_arr: np.ndarray | None, processing: dict) -> None:
"""Push the current pick state into the canvas as zero markers.

Fractions are computed against the displayed array's shape — the
frame the points were picked in.
"""
if display_arr is None or self._markers_hidden:
self._zoom_lbl.set_zero_markers([])
return

Ny, Nx = raw_arr.shape
Ny, Nx = display_arr.shape
denom_x = max(1, Nx - 1)
denom_y = max(1, Ny - 1)

Expand Down
71 changes: 43 additions & 28 deletions probeflow/processing/gui_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,52 @@ def _append_step(step: ProcessingStep):
"scale_y": scale_y,
}))

# ── Positioned steps (frame-stamped) ──────────────────────────────────────
# Steps whose pixel geometry was captured in the *display* frame carry an
# ``after_geometric_ops`` count recorded at commit/pick time; they are
# interleaved into the geometric_ops sequence at that position so the
# coordinates are interpreted in the frame they were captured in
# (review: scope-replay ordering). Entries without a stamp (legacy
# saves) keep their historical positions so old provenance replays
# byte-identically.
scope_steps: list[tuple[int, ProcessingStep]] = []

def _scope_position(spec: dict) -> int:
try:
return max(0, int(spec.get("after_geometric_ops", 0) or 0))
except (TypeError, ValueError):
return 0

def _flush_scope_steps(up_to: int) -> None:
"""Emit (in commit order) every positioned step due at or before *up_to*."""
remaining: list[tuple[int, ProcessingStep]] = []
for position, step in scope_steps:
if position <= up_to:
steps.append(step)
else:
remaining.append((position, step))
scope_steps[:] = remaining

# Set-zero anchors are picked on the displayed image; the controller
# stamps the geometric-op count at pick time (2026-06-12 workflow
# review: unstamped picks made after a flip/rotation anchored the zero
# plane at the mirrored/wrong feature). Stamp 0 / missing keeps the
# legacy position here, before every other step.
zero_position = _scope_position(
{"after_geometric_ops": gui_state.get("set_zero_after_geometric_ops", 0)}
)

def _emit_zero_step(step: ProcessingStep) -> None:
if zero_position > 0:
scope_steps.append((zero_position, step))
else:
_append_step(step)

set_zero = gui_state.get("set_zero_xy")
if set_zero is not None:
try:
x_px, y_px = int(set_zero[0]), int(set_zero[1])
_append_step(ProcessingStep("set_zero_point", {
_emit_zero_step(ProcessingStep("set_zero_point", {
"x_px": x_px,
"y_px": y_px,
"patch": int(gui_state.get("set_zero_patch", 1)),
Expand All @@ -306,7 +347,7 @@ def _append_step(step: ProcessingStep):
invalid_points += 1
continue
if len(points) >= 3:
_append_step(ProcessingStep("set_zero_plane", {
_emit_zero_step(ProcessingStep("set_zero_plane", {
"points_px": points[:3],
"patch": int(gui_state.get("set_zero_patch", 1)),
}))
Expand All @@ -319,32 +360,6 @@ def _append_step(step: ProcessingStep):
# Durable ROI-scoped local filters (review: ROI overwrite/retarget).
# Each committed entry froze the ROI geometry at apply time, so multiple
# ROI-scoped filters coexist and do not follow later ROI moves/deletes.
#
# Frozen geometry/rasters are captured in the *display* frame at commit
# time — i.e. after every geometric op that existed in the history at
# that moment. ``after_geometric_ops`` records that count so the scope
# step replays at the same point in the pipeline; without it, a region
# committed after a flip replayed before the flip and the filter landed
# at the mirrored location (review: scope-replay ordering). Entries
# without the key (legacy saves) keep their historical position before
# all geometric ops, so old provenance replays byte-identically.
scope_steps: list[tuple[int, ProcessingStep]] = []

def _scope_position(spec: dict) -> int:
try:
return max(0, int(spec.get("after_geometric_ops", 0) or 0))
except (TypeError, ValueError):
return 0

def _flush_scope_steps(up_to: int) -> None:
"""Emit (in commit order) every scope step due at or before *up_to*."""
remaining: list[tuple[int, ProcessingStep]] = []
for position, step in scope_steps:
if position <= up_to:
steps.append(step)
else:
remaining.append((position, step))
scope_steps[:] = remaining

for spec in gui_state.get("roi_filter_ops") or []:
if not isinstance(spec, dict):
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"test_quick_selection_lifecycle.py",
"test_scale_shear_geometry.py",
"test_canvas_item_lifetime.py",
"test_workflow_replay.py",
}

MIXED_QT_FIXTURE_MODULES = {
Expand Down
40 changes: 40 additions & 0 deletions tests/test_processing_scope_seams.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,3 +590,43 @@ def test_positioned_scope_state_replays_through_scan_export_path(self):
assert _std(out, 4, 16, 4, 16) < SMOOTHED, "filter not where drawn"
assert _std(out, w - 16, w - 4, 4, 16) > UNTOUCHED, "filter mirrored"
assert scan.processing_state is not None


class TestSetZeroPositioning:
def test_stamped_zero_plane_replays_after_the_flip(self):
"""A zero plane picked on the flipped display (stamped position 1)
must replay after the flip, anchoring the clicked coordinates."""
gui = {
"set_zero_plane_points": [(2, 2), (30, 4), (10, 28)],
"set_zero_patch": 1,
"set_zero_after_geometric_ops": 1,
"geometric_ops": [{"op": "flip_horizontal"}],
}
state = processing_state_from_gui(gui)
assert [s.op for s in state.steps] == ["flip_horizontal", "set_zero_plane"]

arr = _noise()
out = apply_processing_state(arr, state)
# patch=1 means a 3x3 sampling window: the plane passes exactly
# through the patch means, so those (not the single pixels) are 0.
for x, y in gui["set_zero_plane_points"]:
patch = out[y - 1:y + 2, x - 1:x + 2]
assert float(np.mean(patch)) == pytest.approx(0.0, abs=1e-12)

def test_legacy_unstamped_zero_keeps_historical_order(self):
gui = {
"set_zero_plane_points": [(2, 2), (30, 4), (10, 28)],
"set_zero_patch": 1,
"geometric_ops": [{"op": "flip_horizontal"}],
}
state = processing_state_from_gui(gui)
assert [s.op for s in state.steps] == ["set_zero_plane", "flip_horizontal"]

def test_stamped_zero_point_orders_after_geometric_ops(self):
gui = {
"set_zero_xy": (5, 6),
"set_zero_after_geometric_ops": 1,
"geometric_ops": [{"op": "rotate_90_cw"}],
}
state = processing_state_from_gui(gui)
assert [s.op for s in state.steps] == ["rotate_90_cw", "set_zero_point"]
Loading
Loading