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
49 changes: 43 additions & 6 deletions datalab/gui/processor/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

from __future__ import annotations

import copy

import guidata.dataset as gds
import numpy as np
import sigima.params
import sigima.proc.base as sipb
Expand Down Expand Up @@ -707,15 +710,47 @@ def compute_binning(self, param: sigima.params.BinningParam | None = None) -> No
param.dtype_str = input_dtype_str
self.run_feature("binning", param, title=title, edit=edit)

def __init_profile_param(
self,
param: sigima.params.LineProfileParam
| sigima.params.SegmentProfileParam
| sigima.params.AverageProfileParam
| None,
paramclass: type[sigima.params.LineProfileParam]
| type[sigima.params.SegmentProfileParam]
| type[sigima.params.AverageProfileParam],
title: str,
) -> tuple[bool, gds.DataSet, bool]:
"""Initialize profile parameters, keeping the last profile position

Args:
param: parameters (None: edit new parameters)
paramclass: parameter class
title: title

Returns:
Tuple (edit, param, add_initial_shape) where add_initial_shape is True
if a previous profile position is reused as initial shape
"""
previous = self.PARAM_DEFAULTS.get(paramclass.__name__)
previous = None if previous is None else copy.deepcopy(previous)
edit, param = self.init_param(param, paramclass, title)
if edit and previous is not None:
# `init_param` centers the profile on the image (`update_from_obj`):
# the last profile position is restored instead
gds.update_dataset(param, previous)
return edit, param, previous is not None

@qt_try_except()
def compute_line_profile(
self, param: sigima.params.LineProfileParam | None = None
) -> None:
"""Compute profile along a vertical or horizontal line
with :py:func:`sigima.proc.image.line_profile`"""
title = _("Profile")
add_initial_shape = self.has_param_defaults(sigima.params.LineProfileParam)
edit, param = self.init_param(param, sipi.LineProfileParam, title)
edit, param, add_initial_shape = self.__init_profile_param(
param, sipi.LineProfileParam, title
)
if edit:
options = self.panel.plothandler.get_plot_options()
dlg = ProfileExtractionDialog(
Expand All @@ -734,8 +769,9 @@ def compute_segment_profile(
"""Compute profile along a segment
with :py:func:`sigima.proc.image.segment_profile`"""
title = _("Profile")
add_initial_shape = self.has_param_defaults(sigima.params.SegmentProfileParam)
edit, param = self.init_param(param, sipi.SegmentProfileParam, title)
edit, param, add_initial_shape = self.__init_profile_param(
param, sipi.SegmentProfileParam, title
)
if edit:
options = self.panel.plothandler.get_plot_options()
dlg = ProfileExtractionDialog(
Expand All @@ -754,8 +790,9 @@ def compute_average_profile(
"""Compute average profile
with :py:func:`sigima.proc.image.average_profile`"""
title = _("Average profile")
add_initial_shape = self.has_param_defaults(sigima.params.AverageProfileParam)
edit, param = self.init_param(param, sipi.AverageProfileParam, title)
edit, param, add_initial_shape = self.__init_profile_param(
param, sipi.AverageProfileParam, title
)
if edit:
options = self.panel.plothandler.get_plot_options()
dlg = ProfileExtractionDialog(
Expand Down
78 changes: 56 additions & 22 deletions datalab/gui/profiledialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from typing import TYPE_CHECKING, Literal

import numpy as np
import qtpy.QtCore as QC
import sigima.params
from guidata.configtools import get_icon
Expand Down Expand Up @@ -243,55 +244,87 @@ def __tool_job_finished(self):
self.shape_to_param()
self.update_cs_panels_state()

def __physical_to_indices(self, coords: list[float]) -> tuple[int, int]:
"""Physical to indices
def __clip_to_pixel_centers(self, x: float, y: float) -> tuple[float, float]:
"""Clip a point to the range of the pixel centers of the image

Args:
coords: Coordinates
x: X coordinate
y: Y coordinate

Returns:
Tuple of indices
Clipped coordinates (physical units)
"""
assert self.__obj is not None
return self.__obj.physical_to_indices(coords, clip=True)
obj = self.__obj
nrows, ncols = obj.data.shape
xb = obj.axis_to_physical(np.array([0.0, ncols - 1.0]), "x")
yb = obj.axis_to_physical(np.array([0.0, nrows - 1.0]), "y")
x = float(np.clip(x, np.min(xb), np.max(xb)))
y = float(np.clip(y, np.min(yb), np.max(yb)))
return x, y

def __fit_param_to_image(self) -> None:
"""Bring the profile parameters back into the image (e.g. the parameters
of the last profile, extracted from another image)"""
assert self.__obj is not None
clip = self.__clip_to_pixel_centers
p = self.param
if isinstance(p, sigima.params.LineProfileParam):
p.x, p.y = clip(p.x, p.y)
elif isinstance(p, sigima.params.SegmentProfileParam):
(p.x1, p.y1), (p.x2, p.y2) = clip(p.x1, p.y1), clip(p.x2, p.y2)
else:
nrows, ncols = self.__obj.data.shape
x0, y0, x1, y1 = self.__obj.pixel_range_to_physical([0, 0, ncols, nrows])
xmin, xmax, ymin, ymax = min(x0, x1), max(x0, x1), min(y0, y1), max(y0, y1)
for name, vmin, vmax in (
("x1", xmin, xmax),
("x2", xmin, xmax),
("y1", ymin, ymax),
("y2", ymin, ymax),
):
value = getattr(p, name)
if value is not None:
setattr(p, name, float(np.clip(value, vmin, vmax)))

def shape_to_param(self) -> None:
"""Shape to param"""
"""Shape to param (profile parameters are in physical coordinates)"""
assert self.__obj is not None
p2i = self.__physical_to_indices
clip = self.__clip_to_pixel_centers
p = self.param
if isinstance(self.shape, AnnotatedPoint):
assert isinstance(p, sigima.params.LineProfileParam)
p.col, p.row = p2i(self.shape.get_pos())
p.x, p.y = clip(*self.shape.get_pos())
elif isinstance(self.shape, AnnotatedSegment):
assert isinstance(p, sigima.params.SegmentProfileParam)
p.col1, p.row1, p.col2, p.row2 = p2i(self.shape.get_rect())
if p.col1 > p.col2:
p.col1, p.col2 = p.col2, p.col1
p.row1, p.row2 = p.row2, p.row1
x1, y1, x2, y2 = self.shape.get_rect()
(p.x1, p.y1), (p.x2, p.y2) = clip(x1, y1), clip(x2, y2)
if p.x1 > p.x2:
p.x1, p.x2 = p.x2, p.x1
p.y1, p.y2 = p.y2, p.y1
else:
assert isinstance(p, sigima.params.AverageProfileParam)
p.col1, p.row1, p.col2, p.row2 = p2i(self.shape.get_rect())
if p.col1 > p.col2:
p.col1, p.col2 = p.col2, p.col1
p.row1, p.row2 = p.row2, p.row1
x1, y1, x2, y2 = self.shape.get_rect()
p.x1, p.x2 = sorted((float(x1), float(x2)))
p.y1, p.y2 = sorted((float(y1), float(y2)))

def param_to_shape(self) -> None:
"""Param to shape"""
assert self.__obj is not None
i2p = self.__obj.indices_to_physical
p = self.param
if isinstance(self.shape, AnnotatedPoint):
assert isinstance(p, sigima.params.LineProfileParam)
x, y = i2p([p.col, p.row])
self.shape.set_pos(x, y)
self.shape.set_pos(p.x, p.y)
elif isinstance(self.shape, AnnotatedSegment):
assert isinstance(p, sigima.params.SegmentProfileParam)
x1, y1, x2, y2 = i2p([p.col1, p.row1, p.col2, p.row2])
self.shape.set_rect(x1, y1, x2, y2)
self.shape.set_rect(p.x1, p.y1, p.x2, p.y2)
else:
assert isinstance(p, sigima.params.AverageProfileParam)
x1, y1, x2, y2 = i2p([p.col1, p.row1, p.col2, p.row2])
# Undefined bounds stand for the edges of the image
nrows, ncols = self.__obj.data.shape
edges = self.__obj.pixel_range_to_physical([0, 0, ncols, nrows])
coords = [p.x1, p.y1, p.x2, p.y2]
x1, y1, x2, y2 = [e if c is None else c for c, e in zip(coords, edges)]
self.shape.set_rect(x1, y1, x2, y2)

def edit_values(self) -> None:
Expand Down Expand Up @@ -330,6 +363,7 @@ def set_obj(self, obj: ImageObj):
p0 = QC.QPointF(0.0, 0.0)
self.cstool.add_shape_to_plot(plot, p0, p0)
self.shape = self.cstool.get_last_final_shape()
self.__fit_param_to_image()
self.param_to_shape()
self.param_btn.setEnabled(True)
self.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(True)
Expand Down
4 changes: 1 addition & 3 deletions datalab/tests/features/common/history_replay_fixes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ def record_line_profile(win) -> tuple:
history, ipanel = win.historypanel, win.imagepanel
ipanel.add_object(create_sincos_image())
ipanel.objview.select_objects([1])
param = sigima.params.LineProfileParam.create(
direction="horizontal", row=100, col=100
)
param = sigima.params.LineProfileParam.create(direction="horizontal", y=100, x=100)
ipanel.processor.run_feature("line_profile", param)
action = history[len(history)]
return action, action.output_uuids[0]
Expand Down
18 changes: 9 additions & 9 deletions datalab/tests/features/common/interactive_processing_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1429,7 +1429,7 @@ def test_cross_panel_image_to_signal():
image_uuid = get_uuid(image)

# Apply radial_profile (Image → Signal cross-panel computation)
profile_param = RadialProfileParam.create(x0=50, y0=50)
profile_param = RadialProfileParam.create(xc=50, yc=50)
image_processor.run_feature("radial_profile", param=profile_param)

# Verify the result is now in the signal panel
Expand All @@ -1450,8 +1450,8 @@ def test_cross_panel_image_to_signal():

# Verify the parameter can be deserialized
stored_param = json_to_dataset(option_dict["param_json"])
assert stored_param.x0 == 50
assert stored_param.y0 == 50
assert stored_param.xc == 50
assert stored_param.yc == 50

# Test that the Processing tab is set up correctly
# (even though source is in a different panel)
Expand All @@ -1461,8 +1461,8 @@ def test_cross_panel_image_to_signal():
# Test modifying processing parameters
editor = signal_panel.objprop.processing_param_editor
# Change the center position
editor.dataset.x0 = 40
editor.dataset.y0 = 40
editor.dataset.xc = 40
editor.dataset.yc = 40

# In-place update + in-place recompute require History panel edit
# mode (otherwise new objects are created instead of mutating).
Expand All @@ -1477,8 +1477,8 @@ def test_cross_panel_image_to_signal():
# Verify the parameter was updated in metadata
updated_dict = signal.get_metadata_option(PROCESSING_PARAMETERS_OPTION)
updated_param = json_to_dataset(updated_dict["param_json"])
assert updated_param.x0 == 40
assert updated_param.y0 == 40
assert updated_param.xc == 40
assert updated_param.yc == 40

# Test recompute feature with modified source image
image.data *= 2.0 # Double the image intensity
Expand Down Expand Up @@ -1525,7 +1525,7 @@ def test_cross_panel_image_to_signal_group():
signal_groups_before = len(signal_panel.objmodel.get_groups())

# Apply radial_profile to the entire group (Image → Signal cross-panel)
profile_param = RadialProfileParam.create(x0=50, y0=50)
profile_param = RadialProfileParam.create(xc=50, yc=50)
image_processor.run_feature("radial_profile", param=profile_param)

# Verify a NEW group was created in the signal panel
Expand Down Expand Up @@ -1666,7 +1666,7 @@ def test_select_source_objects_cross_panel():
image_uuid = get_uuid(image)

# Apply radial_profile (Image → Signal)
profile_param = RadialProfileParam.create(x0=50, y0=50)
profile_param = RadialProfileParam.create(xc=50, yc=50)
image_processor.run_feature("radial_profile", param=profile_param)

# Verify we're now in the signal panel
Expand Down
6 changes: 3 additions & 3 deletions datalab/tests/features/common/processing_preview_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,9 +849,9 @@ def test_live_image_to_signal_preview(tmp_path, monkeypatch):
source.xlabel, source.xunit = "Position", "mm"
source.zlabel, source.zunit = "Intensity", "a.u."
set_number(source, 1)
param = LineProfileParam.create(direction="horizontal", row=3)
param = LineProfileParam.create(direction="horizontal", y=-2.5)
expected = line_profile(
source.copy(), LineProfileParam.create(direction="horizontal", row=3)
source.copy(), LineProfileParam.create(direction="horizontal", y=-2.5)
)
assert expected.x.size == 8
dialog = ProcessingPreviewDialog(param, line_profile, [source])
Expand Down Expand Up @@ -887,7 +887,7 @@ def stop_loop(*_args) -> None:
np.testing.assert_allclose(dialog.preview.item.get_data()[1], expected.y)
assert source.data.dtype == np.uint16
np.testing.assert_array_equal(source.data, np.arange(120).reshape(10, 12))
assert param.row == 3
assert param.y == -2.5
assert dialog.grab().save(str(tmp_path / "processing-preview.png"))
finally:
timeout.stop()
Expand Down
24 changes: 20 additions & 4 deletions datalab/tests/features/common/provenance_capture_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import sigima.proc.image as sipi
import sigima.proc.signal as sips
from sigima.objects import create_image, create_signal
from sigima.proc.contracts import build_contract_registry
from sigima.tests.data import create_peak_image

from datalab.env import execenv
Expand Down Expand Up @@ -252,7 +253,7 @@ def run_families(win) -> None:
spanel.processor.run_feature("stats")
image = add_image(win)
ipanel.objview.select_objects([get_uuid(image)])
profile = sigima.params.LineProfileParam.create(direction="horizontal", row=1)
profile = sigima.params.LineProfileParam.create(direction="horizontal", y=1)
ipanel.processor.compute_1_to_1(sipi.line_profile, profile, edit=False)
ipanel.objview.select_objects([get_uuid(image)])
ipanel.processor.run_feature("centroid")
Expand Down Expand Up @@ -646,6 +647,13 @@ def test_capture_reference_files(edition: str) -> None:
"sigima.image.peak_detection",
]

#: Local contract versions: a reference file written before a change of the result
#: of an operation records an older version, refused instead of being replayed
LOCAL_VERSIONS = {
contract.operation_id: contract.contract_version
for contract in build_contract_registry().contracts
}


def build_families_reference(win) -> None:
"""Run the cross-edition families scenario."""
Expand Down Expand Up @@ -679,7 +687,7 @@ def build_families_reference(win) -> None:
spanel.processor.run_feature("stats")
image = add_image(win)
ipanel.objview.select_objects([get_uuid(image)])
profile = sigima.params.LineProfileParam.create(direction="horizontal", row=1)
profile = sigima.params.LineProfileParam.create(direction="horizontal", y=1)
ipanel.processor.compute_1_to_1(sipi.line_profile, profile, edit=False)
ipanel.objview.select_objects([get_uuid(image)])
ipanel.processor.run_feature("centroid")
Expand Down Expand Up @@ -709,8 +717,16 @@ def check_families_reference(win, edition: str) -> list[dict]:
reports = []
for activity in activities:
report = win.verify_provenance_activity(activity["activity_id"])
accepted = ("exact",) if edition == "desktop" else ("exact", "within_tolerance")
assert report["verdict"] in accepted, report
operation = activity["call"]["operation"]
if operation["contract_version"] != LOCAL_VERSIONS[operation["id"]]:
# Recorded before a change of the result: refused, never replayed
assert report["eligibility"] == "unsupported_contract", report
assert report["verdict"] == "not_verified", report
else:
accepted = ("exact",)
if edition != "desktop":
accepted += ("within_tolerance",)
assert report["verdict"] in accepted, report
reports.append(report)
return reports

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ def test_image_to_signal_conversion() -> None:

def build(win):
image = add_image(win, np.outer(np.hanning(32), np.hanning(32)), "I")
param = sigima.params.RadialProfileParam.create(center="user", x0=16, y0=16)
param = sigima.params.RadialProfileParam.create(center="user", xc=16, yc=16)
(profile,) = compute(
win.imagepanel,
[image],
Expand All @@ -333,8 +333,8 @@ def build(win):
assert isinstance(profile, SignalObj)
call = resolve_processing_call(win, profile)
assert call.processor is win.imagepanel.processor
assert (call.param.x0, call.param.y0) == (16, 16)
param = sigima.params.RadialProfileParam.create(center="user", x0=10, y0=10)
assert (call.param.xc, call.param.yc) == (16, 16)
param = sigima.params.RadialProfileParam.create(center="user", xc=10, yc=10)
report = win.signalpanel.objprop.apply_processing_parameters(
profile, interactive=False, param=param
)
Expand Down
2 changes: 1 addition & 1 deletion datalab/tests/features/common/title_short_id_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def test_title_mode_covers_common_operations() -> None:
image.title = "Image A"
win.imagepanel.add_object(image)
param = sigima.params.LineProfileParam.create(
direction="horizontal", row=100, col=100
direction="horizontal", y=100, x=100
)
win.imagepanel.processor.run_feature("line_profile", param)
profile = win.signalpanel.objview.get_current_object()
Expand Down
Binary file modified datalab/tests/features/hdf5/provenance_desktop_families.h5
Binary file not shown.
Loading
Loading