From eec787a34be661ae09bb6e3cdd880d6dce533553 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:20:03 +0100 Subject: [PATCH] Follow Sigima: image profiles in physical coordinates Sigima now defines line, segment, average and radial profiles in physical coordinates (renamed parameters, contracts version 2): - the profile dialog exchanges physical coordinates with the drawn shapes, bringing points beyond the image back to the outermost pixel centers and drawing undefined average bounds on the image edges; - the last profile position is still reused as initial shape; - tests use the new parameter names; the families reference checks are data-driven (activities recorded with an older contract version are refused) and the Desktop reference file is regenerated; - documentation, tutorials, release notes and French translations. Closes #436 Assisted-by: Claude Opus 5.5 --- datalab/gui/processor/image.py | 49 ++++++- datalab/gui/profiledialog.py | 78 ++++++++--- .../common/history_replay_fixes_test.py | 4 +- .../common/interactive_processing_test.py | 18 +-- .../common/processing_preview_unit_test.py | 6 +- .../common/provenance_capture_unit_test.py | 24 +++- .../common/provenance_processing_unit_test.py | 6 +- .../common/title_short_id_unit_test.py | 2 +- .../hdf5/provenance_desktop_families.h5 | Bin 139128 -> 139272 bytes .../image/contour_fabryperot_app_test.py | 4 +- .../tests/features/image/profile_app_test.py | 14 +- .../profile_dialog_physical_unit_test.py | 131 ++++++++++++++++++ .../features/image/profile_dialog_test.py | 6 +- .../image/profile_recompute_app_test.py | 28 ++-- .../profile_recompute_multiobject_app_test.py | 28 ++-- .../profile_reset_selection_unit_test.py | 9 +- doc/features/image/menu_analysis.rst | 21 ++- doc/intro/tutorials/fabry_perot.rst | 9 +- doc/intro/tutorials/laser_beam.rst | 5 +- .../features/image/menu_analysis.po | 23 +-- .../intro/tutorials/fabry_perot.po | 34 +---- .../LC_MESSAGES/intro/tutorials/laser_beam.po | 15 +- .../LC_MESSAGES/release_notes/release_1.04.po | 15 ++ doc/release_notes/release_1.04.md | 7 + 24 files changed, 376 insertions(+), 160 deletions(-) create mode 100644 datalab/tests/features/image/profile_dialog_physical_unit_test.py diff --git a/datalab/gui/processor/image.py b/datalab/gui/processor/image.py index ffc525a72..ab2972e91 100644 --- a/datalab/gui/processor/image.py +++ b/datalab/gui/processor/image.py @@ -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 @@ -707,6 +710,37 @@ 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 @@ -714,8 +748,9 @@ def compute_line_profile( """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( @@ -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( @@ -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( diff --git a/datalab/gui/profiledialog.py b/datalab/gui/profiledialog.py index 2f8bbf137..667c42dc1 100644 --- a/datalab/gui/profiledialog.py +++ b/datalab/gui/profiledialog.py @@ -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 @@ -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: @@ -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) diff --git a/datalab/tests/features/common/history_replay_fixes_test.py b/datalab/tests/features/common/history_replay_fixes_test.py index f7f40026b..0bf67ff9b 100644 --- a/datalab/tests/features/common/history_replay_fixes_test.py +++ b/datalab/tests/features/common/history_replay_fixes_test.py @@ -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] diff --git a/datalab/tests/features/common/interactive_processing_test.py b/datalab/tests/features/common/interactive_processing_test.py index 020c53934..a1326879c 100644 --- a/datalab/tests/features/common/interactive_processing_test.py +++ b/datalab/tests/features/common/interactive_processing_test.py @@ -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 @@ -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) @@ -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). @@ -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 @@ -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 @@ -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 diff --git a/datalab/tests/features/common/processing_preview_unit_test.py b/datalab/tests/features/common/processing_preview_unit_test.py index 2c5b8287f..75b1d3145 100644 --- a/datalab/tests/features/common/processing_preview_unit_test.py +++ b/datalab/tests/features/common/processing_preview_unit_test.py @@ -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]) @@ -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() diff --git a/datalab/tests/features/common/provenance_capture_unit_test.py b/datalab/tests/features/common/provenance_capture_unit_test.py index f6ce9b1d8..d8727940d 100644 --- a/datalab/tests/features/common/provenance_capture_unit_test.py +++ b/datalab/tests/features/common/provenance_capture_unit_test.py @@ -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 @@ -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") @@ -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.""" @@ -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") @@ -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 diff --git a/datalab/tests/features/common/provenance_processing_unit_test.py b/datalab/tests/features/common/provenance_processing_unit_test.py index 74c93d26a..eb57f444a 100644 --- a/datalab/tests/features/common/provenance_processing_unit_test.py +++ b/datalab/tests/features/common/provenance_processing_unit_test.py @@ -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], @@ -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 ) diff --git a/datalab/tests/features/common/title_short_id_unit_test.py b/datalab/tests/features/common/title_short_id_unit_test.py index e1d2c68b0..79127afe5 100644 --- a/datalab/tests/features/common/title_short_id_unit_test.py +++ b/datalab/tests/features/common/title_short_id_unit_test.py @@ -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() diff --git a/datalab/tests/features/hdf5/provenance_desktop_families.h5 b/datalab/tests/features/hdf5/provenance_desktop_families.h5 index 3c5cc17a08f034e699f8ac571b3e1f6cf8941855..fd172fe24624924a7f7161fd82fe125b668135e2 100644 GIT binary patch literal 139272 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 Jz)oP`{{t#@L}&m2 literal 139128 zcmeHwZEz&Vb=ZI((vhMhP@*L0lPgf7K3+SE;zd za>`NO>wa%%r?Gqc0pI{0c5pX6-P8U0_3PKKU%%Hq`|bIA?tA_9@3>yW>%al+8f{WM z^6e*dZQOj7Na8rZU!aEx3Qtk^oXoCGh(Ar6qVyXHzMAjIqxascL0*Q>Ar;e%Yvj`b zP8f&#iURZZ-gO8Ye2!YZK%w!1LrI>hP(BHm`FTRS7Pt~FN|>PB{Qhd-Yakx60T-I%6QYXGmv}lUWXrkj%-{j3vj-(6rZFFlKPrfx_WQYvX@*`+tE)hmfk7b$L-yl ztsP^vQxurct`>rY$HgSclcjgb8W;LhjKcO%M zZ8dE|`fCUd*Yo~GnebXlzmdYjwZ+wNxzuchty;5Hi?J+TJWiy-qiJe}>tr^ds=QWe zFiF70`P=E?hDd*%xND#{$!jGIMS&YA&iJmRNe$n;p3)gkvz3UHH>l~W4UzIjC4H^6 zCQ_(-+ITkCS429upXt_WglkX3n>P_0PRDj5P2Sui{U%Ch{7t=8*P-LzLg~Zy=Ub^9 z_uG*NWy0Gio#}qKzFbSNvnieVn)~+_GxPn((cVt)xj!GSA3m`X)tBEv>D(XD^11*; zVzS*&oKC}pN@?1=+fVZ0Jrw70zbx@mG~`6oaxEQ@23Z**I`R0+)C-3 z-*-IEP7PN#!pPJeJMi@|wH@7vqQHn;*Y+b(@&i++hrE_o>KT!BF3e|qO zTgi8i62Di8->1aySK{+Z`~ixy-1_m4W}1d!9xTD#5_em$^v5Y5( zcdIDxfSE{44LyH7MDUm&1|6plE9C~|qeql-2F$XlYdLxlz+~%WfuonxxU8pM+3-`4 z8JUq`kwa8Y<-bo-I`cX6-=mb?i~kPOd#3L#iD&s%pdF!fmH!^2^lK=LPssW6Q$5l@ zP3asQ&pI72lCB8s$rc0K!1Op1DgLfglSMiD{oyQ-P z6DcvJGe3&?7x+<*PHkv@6v75zGG9J39WRH?W_6{WtS_h2bEWBKZLziz&aO4;@oW?} z)9HgH%Cj1-h#dFbcjQpm2v_h;bE%fKpjfN1o_-PF3TA?k`8~7k*z(Ox4;+}BoY0B& zFvwEfvaBS_9C-GGXkMgts{FS^=_>!#D4pr7>I+ZO`!@fT9nX&v?HHx2{I{&+UjbpT z+6pzPO=%j#;dGp{IX0O8hB`e}KX;NvezCdb0^yXmzz-Yo?MtpZ)QV?l5LSPX77VAapgU)#m!* z=5i7sw@>9yYF?WU2G#eUAw0RAJ^8a9J(W)Bv9xZYR^m+t|Uzfp_UTVUeJIW|ob zU|1iRcV!(OM5~eOS3(hUk&U%y2@mGuFiD*t@-jVfokVvG5TQYw__|k4J;w-bGb}q; zL==Wl<>N0AJnny%qhF?U<{OodPttqlzgvRX^_)oi3Z<)j{8dWl{y32?FV}$yzSblC z>y*yHH~4|VZ&Ikrt8Xd!PATzkEAj6r@$V|}pHkvKO>wUGK8k;jLhcvC$btk{kBMIf zz1M*GEePEV^s6*0XMt}9WueOb43$&+e7Li+<1}{TC>mgJ0wa3eQl;^kF&iOG>_9R^mTY;=iKAIm&ico>wR7g)zlB zWVndh)m*8qf6o_R4;_I3pCJQyoU3=rfWWf1l2i$%Yn3aw~h7Mc^ddU{!USa&%J|7jKgjeVE>Ft zuN$)~*Q&LaQ%LFMPhq-aA+}qh3`g%vxBh>zggG^BLEJU1pFO?uL79=| zI29WQ`-B24XO2>POB5P3|1cRgP9z~L6SS4I70M_1hWRt#j01*J&Ro;BPrvZ4BD{+f zpCmk3&Rw8|EZ2MBbF$nFEm&u${^x0-!}%`o!iN?xjPGeuWI5mS6n}{o)&bkITiICd zPf`cINDJ#xp#yBWO(rpi*Yr^?dSBH&hwKD4^W)zWqdhT`+bE|WVjjs z#lO0$@t@=W_lK|h`wuKO{^rl`{7?Gd|A!+l{*OC<>sP!l{l!OKPvuX36v{)NRIp#p z!+*F=!t=?Bcx>M`!-rQ3k3IIc*j@<#0>iVv`I~=-aNtt$ZnwK4Ubo*fAKXOgQxv|7 zLdHp!(lmy{<(N+VUgeKL`=8-*dB&gX>4hKj;WXjK8P@`+7;B)x<1;!NE zcNCC%>s9StY^OdXNgyQ#?AN3Ci@Hqbb(QhneMhQGHh4mNYuEMb{H!cik|MY7`gO*Z z`B|U8WToSmR~!X+{mOQ%L)5~_Bl3aQN9QSiir(L#8RBUIJYKj@wpwkbUx1`u`(vqC zJ7BmMsKT{2+=_TYxM<(0R@c{SNq_s!`=AuByKwE4@8i9W@LOG9iPDAuPI;wlsfZ+& zKOE-e4-g*BCGhQNz1Cq|j^W>+%@f?$Q~7wkaXf9dP6%^VUwakW|G7Q*oUcb;NAS?+ zy4>FQG(29{5u6NNU@gYGpB)Xw3gZQ;9pEK9Z~~rgm3**Ne`-05QfV-lf&xezEF)=j zK)dg*2M^y1X-6J?3$zr|@ik~@5fUb~$=ZVq-vO!DKE_MEq=QO080Z{{2 zMckQ=9OiO#IRC+o0FC7yx^!|l*9tj$444Hg>?c33?|_4NqTEE>SUz!kkCq`3u9pgW zoYvFdDRYnS`;`K!oRecf)1LQ>6yb3oXYJyLgQ)acbA06azVz%_x(Zvb$;V{Bk zEywzqO0V2bSk%u3w8x$AS|0SW!OBlox|RohY_Re#-`TZ1=wXAEztFur=wJQH|IO|G zzrW$piSGD=-qo-Cd$0dz-~7Rc{<3@hpl|gnFZpz;d;Oqi4Oad@_wt}$4OSlMX9!eq zKjScZ74xIIZW59+1o?<)18Ja*((ZH5^+Sm?3m(#?$5`=V16q8j!<51uyM)lX^$JS<8u3Qf6(>9UYM<~##J;r6a9u+kG%=X^jcTV zEA@`YtHPk;Xn_K~#_L70*Kua%Z{}xCA2sChs@ezhamV8od@$ghL|X*>gR3I$jE{)G zjp3<&m0{t@I&ge-Klkw*`(X%y@%Z8%7>C^`&})1hB0l0W%+K3BzEuC=cFsd@2IO=5 zqTDC329DCSvD(+Z*lEYTNx+c`0@_<}-$%2)-iTA$`~mO#&5xb?Ki(qX8F0sOJeVAz z?erSY-}8@&;@S&j-^uqmObPs|)kfM}Uv5>!R)^|Zy;;N4!+r5YaBNq7tH7_joViH* z-Y3nQMe%D-{ z%KZtvVXUA0Lw^g)mAApO3MZ~F*Bg-D4-W2aU^r+C$n8TCpQma`YYEZ^vu|7k`$h$F zOF4{keI;xh!(Dj&@IXC`@pwBt=XAano?(8dJuQ|_z50j!PwIS^CW|T40`cmHN2lKs zkL>2{k?S~w$238BgVTlQJ6aEh@yK|+Q{V+gC%E`rY`DC(6heG(e1@AZan8nz$CG;A zFsHkm&%@Q#I(}QPzKWy2Kix48_y4=#Ij6g{vu@B9!+!nO3FqAfIFg??)>msNclyCW zxyksvrvOLdb7BO1ZZ5!)_?#FJA1K!;j*qYXM*&qIEfERkiI12f+kGyc z*KaQ`$r`rvJooznUdZ6_EFAr-3mzM z>(JrOb6rIZ%FFNmd9DwL_EZFB6WZQ6&ov|PV!d~J&vWH`FuGmiwgTNG9A3-!TfcSM zH9jcd6xYkVh97*rTmdLJ&&l=QD0U4z>bzTajccK9UOycacvRqy?b%(sMkiiq0?)^- z?C+hnYfK6%s3^29PAScsQPt@+Fc?(VlwQMUE@4`NP9c&8hDg$_v{+yUln!@ z1D$7rPBJ`7@eOnyz{T?5C4~><+x>g*W!Jb!bQ{|>_AhS?({fer@_0K#e8jMJ%C4b~ zcU5m6#jb%X(0qL}oL%E;YH(hD_w5?^$%33Nd&jOZC-7puckkIXK3t&N=ynY}LZ9=4 zgtHrVjXMM$2Hd4yaXI&E;J9GB1|D_G`Ehsc8l8CIkvp}YF1KAn{ho8a-rBldqf(&z z=yr{}3UEfZYkaH#XLP&9-32(K+coYHa9AGgKfA`gqW#77{Z{N6_^A)oPP7;88uxc; z_g3s0NWorq4gTSu1;~S!^c(j6z4W!O%6zO>$=LY5UnrpJ*ClHBD_<^BV5jUFYkw!} z*{WRweMIN~Qo_PHx7Ygf?B7y-xWN-H^yA>#VM@e6hk!r4rvA4~I7JH!PB(ra)3=K6 zO#p`Vl?UK?_v|p*EwV)w8MNO=UrtrIvtBai{I(R%vuR7=MANJ$Rvp+3i@VM>|ah&5y zqJYYmJU^c%dBtt*I4>v;7%)FpIKzkdyHd;XepZ!Uxtx)9ME>4jzx{`JwuaYv z_=%RTaA4!qFmN8~1qU`x4Fl(40mp#5lz)SKhYHu$crl&S`7qztINbSrhNrIUHq&eP z--UPt@XhV+!};6DOwdt&_x)`sGjh7@9e>*=1zu93ZTIg&e5d;?Qgpg>cp!p&y?Rq&Gp}{ z``bPZ_2hIP-QRY!07vrkDEo*%Q-CwNzwNUHI1-;x_7USx-m3ng{d7M1bE5sl_10GW zZBKNe=U();;Zf6koJo1K6@MEZS?uL+JO4v?3D<(S_xHCM|3GHsbpaI{2m6Eq>iV!m z?Oq~2VlwQMzfJosS;JQSSNNS3o@W=}dF%eGlft+2Ryepf3Gir}Ej(TN>UM6cMn(FK zv2JBA@w?jOCp)PigxGGr)br6Bzbf-T!2wpeAQ z8?yGZuYC;9{kp7|_c4s^*ODINdv^+`Cj}h2gNMH(xf}NEV*(EzZt=qFvMCbfGoIShK9)U-3 zb2!7y{aDtp zReSaa01WH>Pr>un?b%bpZrRPoj2QQ}GoQZ&-|zMBea`<#V#(|CHHx497c!mY!9|4+ zv*{p`Z;>%i;j0=&`e%((XCc0MEEvE6GlJF}{P<#Zd} z&it&vi|vBL?VElLl*KEzQ}4c=8Mk8QblE%mroSZck`it&?wiI>0pxTW-OhYcz+rxp zaCXDa{1t%*kGIQfXU1{Cqk|7Z{>HTzAoBV5f$)vyhr(6sc#hENILNrj(+nA>Kn`l-z>lx-Ol{20vw6YDEp>Q z2{o(R_x4p6z|nwXT~Ff`8bpEXe)N+pAz)DQP{zz;2AI85B16m zGQJ>#zTYWY`ZqEouM?=)IM^o?P}hqkYWG>{l=I*}2jz2qkZ^XxuJL_=$L`oQa9=5}XMbMcQGt8De%h_y%kISM2Y`>- zPdjbbm=sjV*FER|i%eLe^nAUw_5E|dAlg?Ekhfyj_{9R8(d`;%3UEfZYv7i{T<(u< z*Z5@tN6t|D@%OTE%Vy4RTi-v2M-_AVF6~5nY5yF54;FQk`|p0_L7}72fMk6IUWp6$ zeRf{7%+qe8#FhGS{PJOVJZ*%F=}glw{M(*w)}Bu9un(42>PdP>Bh8l682vhU$+eiK zy+KB`VtL&EBQLbO;$`Zsx^8MP3(1QXXPRMTK@ujGp7>=`cZ|T%gV4=%+e@=@7WigR zcCiHOB{7wMW;$LDo6Rbqw!WNB&y}W|wZ+;>IE$ZgoQ=X}I(@K2c~-*}k>erJ#=B$_ z4~30z1@o*zVQAwtHWJ}HdhdM?-h20vY4oP#zkkE{x_%nkVeINwkO01Roam8d`?_VM zc#dLVEwwmJp&g(_0rg49~e^Dm9j?%f@;rend!9tXN1BFb7Tl@_F zwAWKQ>#Yyh51&|x>dV|O+)s;*5Z#2@8!5k<&T^by!+z-Eg?>xCn6I!cc)eK$?fXsb zC;9LeCH__={x&7fb~H|YJ3Vkc(7jo0Ii0B)hIQMsjrvn}08_#B4$8-LS_abLCp|GU zzIrFUXZ~Y;d>5s+``7d>Gx0M$v8`Bl%BH0UWiQbK-z&Qj^m!Nyc<-iiU!m~Q{0NM> zn9imftwtEPYW3C4yop_XNTS>3%%s+U0f*UvEtlW~<Am=Mn%=kh zHHxFeviM`n?cbGRer5VIUq;L8g3j+JIBNQ>l+M8i_<_P13K{PeJX`=5%R3p@=Uu^+%e~2?scu$@kP=K@#Q+ixqiJ;4qDu}JsaJl*HDhgWY;}p6W z`}~f3!+5DA-5-X$*rQ*G6&g4rxe=XR}<{Iw~a>Foqo?7La0 zyM}A)4oc#{b~4>JOgHmf$8!w_i-;S9T+Vkq&rS_jH^K>K8I*2Uf{Ue_~EariFV*xayJ@ydI4?f|? zbb%)LS(JpjVHt_;I8mxcwx8&s>$#cjnvoZYA_0}-@o~J?s3o09RCPEPC8t zuX##W>BaT&ycol{YSe4V$0Op_?ogYFVCHpbueOzBXUSD3v z8AZHVruU4OMe_npk8Sxr=(vg z_MfSAJSm{%>pPx*zC!67e3c(4{F)N~x)T3}691+W|CSOzrNqCj#J{7&zpKPKdXXQl z@R0op3;$X+^yCXNRP7<7>~~;01CEhi=jslfl=UshpznT%qyJ52mmWRd%pui2LqQmS31ngC=KEk#Ch3-aU0@tQiVbgcjiiH7c)zk7jO1$BHe}c zLe>(mZo_=uTq#VF>SDMKza|o{R#)q_rm(1)w;8i$$(Y485#;mdO7~$H%z=Szv-J&) zZ?-VKbz0sae|fG{u!6vn&}`dfRJqCRyTv70xDm!eBU5>#btOtQ0+bZe%Mg`-rP$kB?PV~poP&<1&?9t zxzh9Vx=N*&x~>}b9N-&(aJ;U zuj>Jyd_TqB*)PZVp-naCC%F%2nEi4L=LP{sdU$x>-R0ab$9TM6;9AJh;06Xz|qrj`!PdmL|?nbB+FP3MA{;Q0yk=-Mgx1;ZuW4ccQ=$!6SJ`QsZ zkl`TCIUG4IhB*hwaNbmaGyHxzhV$kE9Er~`=KvYbO#+V8xc1Y2Id1=3MEhA^7=6DS z!^6EjxqRQd`{mx&rQKVxi%$W(UUu>MtE9%g0D180XUBN{YMGz)3R!A=-!BwU*NY`; z_;YOwuT0o(ySQ90UaLuI2zC6DtcTZ&>UU8Wil?~@>TwyeBgXb6hEcViLzg|fY1My-Xt;G>UaBau4DQ7c6i=B`})HBBoQkz zXy5mieOha8h=cX?Y6`B`flF~uao>Z{%#*rejHwk0xJLVd@)0Ejmt`|(sF%?UyK;4_WJWQ zUbvo@dHz!H7_qu5wiw%0RC?uh6}iqG)?SY`J=VkUtVwPU8Qot0P620L^u>1A>)%y? zH=4cvD*%Jr`ECKvApA!AZl9`u<#Zd}UjH6}7u)TIv)7|-9Iw4(ug9}DIbHUSy}l&y zV*A+Mv)8}3K)2ED_0s|l^OJqX*hd5`k)z~4+=OekM_@Z`?&od z676SwVe9t#gI(yk7tgorUD~}Bd;KiHn-G7RhPHdWSZ|zqtJJuIDH-;&cdWfr=HqpO zgf_nK7YeBB#Yt-TQQ{w_!y=r}f(_?w>$Duj{C4!=o@RaRpF-`S%>PWrM==70DO9{F>+QlkZEkFMmS2t}8#De@N!LpwLRScbuc~ zB6DlnOB8?Q9h{;77)J#Kc-~U!mFw?P-x~D$od}fmjoaaQ0=_7LJ)p{&(tBmUEWkUw z`pKEo(=z{z%s#&FGYYVrIZEx8t6WVxN%IerBMDm}KEwAGLVm{hMqIBqlBRZoLdi{< z){mdquYCZ4z%})5S(3|FD84{=%*$+=_R(U#8H&%7T$HO2fcJ%Bz6!+`nlioJ-ftH3 z9i{jNIVP5<-r(1Z`4%XC@;78Uqj$MO)-N_3iN_1LP=sq#IrCvC%W?*Jp`TxyrkVD4 zr#8n&{XM#~3qG)@0Iik_(B%8VAK3%%61(kM(Q_z9lcHFXF0N0+Be4I@=!pXze-eX zg~k23XFn+U@f=m$kAEj;$NbBEv>!v2e_0-CB-fZG9lskUK9wT5X7jJLa{hjy zy3RX8!cy+be71F`yWR0Yop%4dKi8TA=l?d_iI}K2hY7A zrp#xI|Iyne{@mSCZjHas52PAjT_~W+E!MxD*NgIkV~4uH7$r%STZcX*Yv6h|D1Ju4 zW0bnCGo+1vnyuFjeC$-g9+E}YEi?6@- zbOC|HV7=e!G$C7v-zCFRuNuK&Yn|-wGrI9!#ejiB8AT zbgc?s9IQ6N)y0(ajZUX|0RsICH;I18@SCjs*}lHx^BO44{aP0I${7Op+sS@yF4dn} z4x@Bg)H6DsAiUtheaLd$CpG+7dN2CnS!29dFIp(e9y*H%!k5`L++K}YX2II$0ewO7=Y_WW@A=@)p{Wu;1Q2_m_ z$Y%+CuyOeE*UI*<)WoMT2P@y%el8!177SMYKqbdtmOrt@`cJG3Q@>W(Y`>k7^qgQ0 zk^RB%miu}kOcUV87O_LKh` z@>ke~9uIq`g=0*%LIIZl=Lm?@mcjO$Nxo;f_dLZr*?w|+;gdW*+oT&m>>~=W+?G-$-&b6s`J73?#&Y;Av1)A?5J5ZHRGocIv$~PYu z^Q}?*bjl_BAowbI&GX;Xa(1( z73}v;S+;9T5lSrg&V5R@^F>ORa$vl#ZtwKm-oiwc13Vv0()_^K^5{9l30n-+eRa=0 zDr?|+UZnWNBb>6u3a`-O>`ztcm9Nu_`|A4nQ-1Bcv%mN9KmNZR`ye{X!+yGc<$wIz zo1eY-ul^5|he-o2-2Xes{x`M{aC=nwJ}f-unh7{5hR>>;JWcb#DS;*XbH;L#$T1ES z3iOhbfudJ1-))zi*RzkJn4@3+4J5 zo@)OY79Q?v#;ezPoU?2%B7%&^m!#MD-kk!y#@9UY5trJj@uk{1w{sqP5RlL9b)%nu zdq}{M7L)DxuIs}B9_!VE$r0Yq!1H&7_F1sJ%l#}hC;{KLXFaC*14oQwIafLDP#&clWCEb=fZr1$f~h{ABo$9)0L zkmq7$wSy?adrAKJ)8RzRz&NINVnhP}g$?wf-dW5ffv(&&BXM z;<;xf|83_vlg9z+&H9Yw@SQ(rvhb=nXA-o!>vi1Ulk{-sN*=-OL%|D@aIuJ2WaK%^5M>-uw6X&8*KeN$`b+~)LL;q7@tRxJXRrkh@gGT z?QMJiJj&+*>wF!uz2{MewToU)6l8to^jGAF-3hyBRkXXv&*Sd}wOJS*#&*$M{*LXU zot!c1^X7Gg+_|{l?V{@MDX99xD0a~W=%gIZ=yuVt0B3Z&XjFhRx?MCbz!}{xiU*Wb z`ix>1#gG5x^XLBiUQj05KM(iZp0^>tbJMoN_1j;8WqE(aB0Q_~+>74}S`v5`+r1UL zXidPYz&)qqXx|I!o`mPk`i$i8owtjge^uB;4}DwGo%>e}*)ICrcV#}#{UXIL z{K|S@U-!~`C$*Pd^ynGcuA*JEx7?liS(*R9-f7wxlgmf} zRqmFk?#b^J$H(^no=^AR#aR0}S?@W*qn|(Z!Vkv$dl}hf&sC-Jue26u+KZ=){JYb? z=d(oPg=;-c@pB3udyas+j1b##574~DewW-YA=f9vo~!HB!;V24^L6~_=Na(J_^i({ zpE3S3zbJeD0?~84ub&7p4!Tl6m0PTTouc`S3$Z-malz@MhU#|*O5c|?a6R)BUsCWG zwZ1F09Q$=udgXG4{krm8azFdl`+!iqZd`%qu5jSo!Z2{~hZl1H+Gyt()Ojq2H`qA_ z_A@iR)OFkDb`I|cuZwmDa7Xz(oFAOObDPgMTla&n34D$cK4U+)lx-EFhX~rYSCb$7 z3xIWQcivq;IO;cevHo%9td!t8;RkO(J$XNk&j%shdA`Kwu^$}ygXQno4=#CPYj<8} zFr$sn2Tcls9{r{qc^l*xQ1ypV{NQ+QHHS009~{rQ=5R*$gRd9hjP3_NUVt;YAN;8T z9Er~;e(((eN3LY{qsNH9Y zkC+TQl z_R4h;f0_=jinwbBD4eA5CJLu0d>4f!3a2T2ABDG4I78tFDLhEwEQPyOuR--+?)GxM zCe?qr=&aUjQT;pJzrE_Uss5eftA6!5RR4DK^Pu&*RR4C3qhagysQ#^v-%;!Jss3pS zhm{*!tvA@^ddpP*pmK7%+ROFMQT_eo^G?teuU~*=cCJ2?;TV>&x4n{ z-j7zKAMTpAQgZY;(o(eU7wbdken;lx+%Hl5mEV;qB`SXUUviG_@OXW3_V+KHuY2je zliF(^+}e+1yNdhZ#^;0gPS5QvOw@JP0YYzz_=vIP(Q=3rwivQsR2|2;U4s3{!}~?Q z1i0mP`QiMc*HXK3d)3zcqF)yH92Jnq=Y!$55CtVFL=O?PZ@Is1@1GAo30UtHzv%Tu zLH3KD`vW;*cfv3F718b@KabA`QwNCP)qXyB7VN`;XbIY{Zh{eef_|EPAiJ>O54HR- z-%XqayYjVQJA;cRp8f6z9MeOvWBbPX!@{pi202M~VpV?_#V`6*s3nIpoL>|XWc%;e z3UEgEi+;TTXE?uTCqCaOz!}{y`pp8I;rybV_lJ*VUDo)7*g%wJcFscTALmG4)Z{9`Gv7ASqZU!Djt z4!TmH*ZTbVKO)75N^xwv*XJ8QmXOa-efhk24d{ha+5`9CE}~0(m$b)soqLTB_P@&2 zMgM&R7yne^$K#C1F%I?#1=R5|L+yU)Pk2Pggyl3@OdHiFn{_-LplQqXIBeA$O-|w5 zhRidY!Yl7S@1>(^XeV)DF;h+pnG|-6{aJcQDA9rk&x3JsCXbf{fPKw`pQQn0ho$!r}#6|@)y^q=caM%4tzCUUu(0v zE=T3`LCRD;E=SSyoOy8ise0pBb1jV1YAu1%hFi`&GxT&XNXxpDx`7^KcA{H><@sUe z#gXj+pmg4qpPHHDm|;1JJUdLy)VH&8WCd9g z_?BU(w(Z8LW4nG5W~P~yz0kMJFh)>mB7PDHTaq@9wd!kFolMISpEhUhS)=1kBRyV2 z$uK>)y1u-8aC+@TYYAh|OxIS{mQzGD#9vwhuy-Gl?+#A4=`d~2n&zxG{Y5Ca9JaE0 zV+ArkRa>ahUZLzizLSDvjalM9J z0x3|5Cy-uUUs*c=3D&GLi{P4ZO~g&iU_-)^E8c>sUdpulT z$9Kd&p_e7Tmt=8Nc1$aYe8US9KZ&x$&SEb}5+_YuKTM4zbq(7}!^AGTkr~HH>ZBI1 z56~%TomfMV*>XK>iOyP1R~K7L({s+j=|=r2=%mM|H=ynl(@z|nUSF+A2Hrp{MC#l$ zf?2Muh&PWzCKyS{dYmTJ2s%ynIGnm$Phl3Bo--hG>iK~m1&$v2k*hmd=IbF~tVc;` zI8Fu(p8<(rlyvmSAVt(3@g2{zQ^VDbFfw(=4xpaYc61|(0wZ!=+mDc6x3NdcR%Qfo zLFiX1&E$BCYKS-G4fcAQ#)(jx;EQG3L(V>`fld?!q?M=ZUZrJinko?%3e z7XYhnbC0B+?b%Tn22NxFZ>6T^8?Kw#P84KOn1yx-Qq~G=%SbH03|(i$ZaFl9AS#=g zwJT%9!ZBjShG9CsZpSb=IjLjnVV0Hk6xh~|Bg-tuo21qtMbsWKKxU<`<>)~GqRYu( zRF=~i>H$XeQy4#)k!^F2yb8w1bY?n96nMa>VdChHnTC4VDVw@&m>zV47p86*$db!7 z8~O|sfzURRvTlVI(9$v!z3iEhZe_6*hMrw6$1txI8-bm5mufJH;mq^6eb9L#pK8S15AniG1K8vI55dzI z0I7SdPC&=B1GnsFneB(J=_SyEFyRIu1i~OOVlRZgjLKy*@ne{|+%S$h`!24d zZn$B>TmU^VsG2bUG~^cDcgcQpY}+>s!_!UQ^mWIFd8llGAhKdmV+<#@eM{(mZB4=j zaFOqTqGJR9r!Y93vJrvKmAZOpWo6e1jLcGEDBR-l?`h*!*hecg$15%f%;K~?g`6DW@bhTY8J@Vpd7na z8e~p+Q=f_D)W8F6Bz@e69&ti50}h9NNI>Q}ATD%}?~ZQ4AT&Ub8a}I3`Cv_0@z5QuN;Uuu46l)4RUf*kLKk!=9nCgC^qyLWy2>p> z=ETb^SWQ^aLom%d9&}CsItb{Ib_RV`HbEpPJv2}ewTEJ;B?6hIn;CQt0QX_yv;y6S zD=`cwwNo4!+c~O2+Ydq`^Fz;feXuFSew@IvDlkmLhp}uWX6##56ggnDO5!ktRIp{) zX5zpSE8glVx7|Ynbj&oEM&cCI8t4TB23LxTybJtm!K?u05W@zuOkr@DXraiw(Dob; z<__0`QV~oWQ5HLTY`QQYjSys_q4eEAMby4aGZT6T0K<9?>dE}F9+|M-Gs7f~zzPUF zALo5H9$nIgWTMH!GhppxgTe>XJg~h}4)qAMd)-T7kmw*%!9c2X(LhDiE{aW9;5cA9 z^1yBeD?uCVR3UPaZ)7f74?xk`&Mxw8&nU+pFm2|WF~|wmaT7qn3NynuL1Q;VT+~>Z z4KtJz$DwVf9t^J(hB|11>Fx-?=?In*mhXa$!Eo2z!~*RGMp2xi9U-5@ zOtf-%p!CBk(a-}k!1*r%;+Dfuj}sSG%)a9WUZi&7AVt(pgy{pUz+s@LDOx#V--a4N z76&mb@4<`;(k$A}PIR5J4Mqb$uq-#SV^D1z7~ml&hoD?J30U=A$1npAG_2I|Lq7pz zTv$HGAY4E^^^lM}i<#a*(|cn3*39bcW!h*#593ztIQSOOivIXB96cc|Pf4wsj)&+BVxgM1L+l^~lrh`?EC z5uXjg9U(0naecMb0CaPI2MUO_uo14LE%4m{G>F$$*VfT^4Q*-EmxURO_4FIGX9N-{U>|p>7WQ6_V7oWi9D}J44fIz|x0~!uc6bK7+$UQvlj**U` z>MD@3opBZ{{BgS*8nxCE(76`3djx8$ueVfcH`3<%atkWyH0uvyvi#x`z!cEM>4xx- zH0m{WE3AagWBj~P4VS^Ef}Shv#>TqrV$m|jwIk1La|QS;ZMLe5japJ&28UJHs4mr) zlTI>#!w>v2;GU_2n=1t#ME^!BY%Hcw2@V?^D4kkihoet2J-KSO?CEPe^`IAWM_;`G z?L#MBy#f3iHck+E*MZN0X4vJ3wxD?fo~j|>Jq^PT%q)g(I!8=r&Nb&;Yu2*-&q6*h z64shay)(MArel2;<``8V3}|*M2!t#Go0kWUpA;^T{ze%LH=vuvnIC0QIZ8YSElq<8 z1SuDq)mFXAn$iH00Z3Qt@OBE9*TLykaA^WRJq=8sh0D#fBc%$=zEnqfv=VOAR@PT= z#vKcng41{hgv+Mh1*#7yWd<^`kEIMagm+fTfbejG!K94s8r`JKfJWkilyPHFkHOom zd#MlhMnAT7a0i2;nS!$<1`A7)45M~r8O~vY2$tqj4f4*l8tdsLBm@B4lJ>Eab9)8~ zQY~%FVw(paZUcEnMi_2lzP%!*TX)v>2ApoeiP3c+3}$&R41}-@I}^Yvt(QYs|2SZ0 zgGCUG1}kyEk_S%U#K%R@pwlhv6sRtS>&<2@T&=FwYfZER4m2)O)TDgaD6|gyL1sb6 z0@;pxfQ(u6>GYaP_w+!iErXT_D~Q=HR2h8a4CI^I=Of1{yMs+3E@s@Us19gtFN_>8 z5rp6gPINPZ4PD^oO7${$;Pk``zyyWbtB*#+K}XI?{W!2nb*KgGG-&xY44FD^qnmD| z*)nWByOieHbGQuTOxxKtW7Zxn3MmF3Dg!y7_W4kO?PY@v6|hfr8!BNMxZrax>t2Rz z52h&{{HYdr=)rvDc^2%CLepnqs00D-_y9X>2>Z!&aN(r5{Q|a=MG5S&$|57S&~rNI zP(fP(8fXTXS6;1CM;f-)!8S50o7!gtgpF1WcCiQ(2YGZ_VFacQ)#5qmZqyWAT}Kc^ zRUlAJY5*O)S2ft6i^c*nV1RO9fh`vtJW1l2u3e5&aLc9O8BDAgoY*EfPQjsThprFX zo!qizCy^alR_6O)Ml)fr@ID`mzB%Y%v|)|3dEOe(kyRLsu;CH5gSp^lcR<;5Y|qpq zH-!BexQi=*rBL9ZQNHhB6y}s!w2@W8^as*=P*pFfHDH>O`^cBT(DF2x@x-oYFtdp5 z^l=?*z36cpTI(Cgo4rHRn4|~TBhl&%tt*nI&k*h~e-7mD-Phv=4gmNAjT;Xvm|gWf z*!Sk9;9`O8v$$CjwtR#CE=qLpMuUukGXSv5KujDw)w%y)71EI@YwwTtzx+=`r z#lscufZI^Sbap>MsWK+LCN=a+f};_fZUI<>;1~{QOyIVI-K*e}f{hJ9;H707cAda} zE(f-?T0We<31CNPY{L@Tgw2urd~m`}+d(u;7@XY~V*`4P3zHh`sR6keTd+kP&+~W@ z?Dbi)yxG9(Iz_r=)An$W6Pz~*V9geJI&66nyZyjH2m4E+SWaFYQzY(*FT-*`Prz~r zW#CLt6ye5g*qRc-K9tNAJE8|vE!WcUSTzA(rBoIN8i>gI|Tk*={_(3 zmj*nSHBueHT&d`Ko56~2L!H=$H%Xd%7)t|gweP@#w%>JW>H{m`?Tao29UXc4~pxAiK`z zMnTM-E5SbHHBrkzcD#{lvF1t-h$;r!oYPr_B*`>%PCvedmcT|KWF+%8QP)7blSiw| zo+}-eEHluaiP0+Y=1QBlbIA6=wv`gBB1JO?vf_`1L3yr34wo4mC$|ARnxf9ZJ`d5s zkArt3j7ibP5_F`6H(U1RU|($+sY>9^! None: panel.add_object(ima2) panel.processor.run_feature("contour_shape", param) - param = sigima.params.LineProfileParam.create(direction="horizontal", row=554) + param = sigima.params.LineProfileParam.create(direction="horizontal", y=554) panel.processor.run_feature("line_profile", param) param = sigima.params.AverageProfileParam.create( - direction="horizontal", row1=550, row2=560 + direction="horizontal", y1=550, y2=560 ) panel.processor.run_feature("average_profile", param) diff --git a/datalab/tests/features/image/profile_app_test.py b/datalab/tests/features/image/profile_app_test.py index cadd3fe9e..c2b7bfea9 100644 --- a/datalab/tests/features/image/profile_app_test.py +++ b/datalab/tests/features/image/profile_app_test.py @@ -33,7 +33,7 @@ def test_profile(): ("vertical", 102, 131), ): profparam = sigima.params.LineProfileParam.create( - direction=direction, row=row, col=col + direction=direction, y=row, x=col ) proc.compute_line_profile(profparam) for direction, row1, col1, row2, col2 in ( @@ -42,14 +42,14 @@ def test_profile(): ): avgprofparam = sigima.params.AverageProfileParam.create( direction=direction, - row1=row1, - col1=col1, - row2=row2, - col2=col2, + x1=col1, + y1=row1, + x2=col2, + y2=row2, ) proc.compute_average_profile(avgprofparam) segprofparam = sigima.params.SegmentProfileParam.create( - row1=10, col1=10, row2=102, col2=131 + x1=10, y1=10, x2=131, y2=102 ) proc.compute_segment_profile(segprofparam) image2 = create_noisy_gaussian_image(center=(0.0, 0.0), add_annotations=False) @@ -64,7 +64,7 @@ def test_profile(): proc.compute_radial_profile() else: param = sigima.params.RadialProfileParam.create( - center=center, x0=x0, y0=y0 + center=center, xc=x0, yc=y0 ) proc.compute_radial_profile(param) diff --git a/datalab/tests/features/image/profile_dialog_physical_unit_test.py b/datalab/tests/features/image/profile_dialog_physical_unit_test.py new file mode 100644 index 000000000..330d3f3c5 --- /dev/null +++ b/datalab/tests/features/image/profile_dialog_physical_unit_test.py @@ -0,0 +1,131 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Profile extraction dialog: physical coordinates +----------------------------------------------- + +Profile parameters are defined in the physical coordinates of the image: the +dialog commits the drawn shapes as they are (points beyond the image being brought +back to the nearest pixel centers), and draws the initial shape from them. +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +import numpy as np +import pytest +import sigima.params +from guidata.qthelpers import qt_app_context +from qtpy import QtCore as QC +from sigima.objects import ImageObj, create_image + +from datalab.gui.profiledialog import ProfileExtractionDialog +from datalab.tests import datalab_test_app_context + +NROWS, NCOLS = 20, 30 +X0, Y0, DX, DY = -10.0, 5.0, 0.5, 2.0 +XMAX, YMAX = X0 + (NCOLS - 1) * DX, Y0 + (NROWS - 1) * DY # Last pixel centers + + +def make_image() -> ImageObj: + """Return a calibrated image with anisotropic pixels""" + obj = create_image("I", np.arange(NROWS * NCOLS, dtype=float).reshape(NROWS, -1)) + obj.x0, obj.y0, obj.dx, obj.dy = X0, Y0, DX, DY + return obj + + +def draw(dialog: ProfileExtractionDialog, *coords: float) -> None: + """Simulate the drawing of the profile shape (plot coordinates)""" + zero = QC.QPointF(0.0, 0.0) + dialog.cstool.add_shape_to_plot(dialog.get_plot(), zero, zero) + shape = dialog.cstool.get_last_final_shape() + if len(coords) == 2: + shape.set_pos(*coords) + else: + shape.set_rect(*coords) + dialog.cstool.SIG_TOOL_JOB_FINISHED.emit() + + +def test_profile_dialog_commits_physical_coordinates() -> None: + """Drawn shapes are committed in physical coordinates""" + with qt_app_context(): + param = sigima.params.LineProfileParam() + dialog = ProfileExtractionDialog("line", param) + dialog.set_obj(make_image()) + draw(dialog, -3.2, 21.7) + assert (param.x, param.y) == pytest.approx((-3.2, 21.7)) + # Beyond the image: brought back to the nearest pixel centers + draw(dialog, 100.0, -100.0) + assert (param.x, param.y) == pytest.approx((XMAX, Y0)) + dialog.reject() + + param = sigima.params.SegmentProfileParam() + dialog = ProfileExtractionDialog("segment", param) + dialog.set_obj(make_image()) + # Drawn from right to left: committed from left to right + draw(dialog, 2.0, 30.0, -20.0, 9.0) + assert (param.x1, param.y1, param.x2, param.y2) == pytest.approx( + (X0, 9.0, 2.0, 30.0) + ) + dialog.reject() + + param = sigima.params.AverageProfileParam() + dialog = ProfileExtractionDialog("rectangle", param) + dialog.set_obj(make_image()) + draw(dialog, 1.5, 30.0, -4.0, 9.0) + assert (param.x1, param.y1, param.x2, param.y2) == pytest.approx( + (-4.0, 9.0, 1.5, 30.0) + ) + dialog.reject() + + +def test_profile_dialog_initial_shape() -> None: + """The initial shape is drawn from the parameters, fitted into the image""" + with qt_app_context(): + # Parameters of a profile extracted from another image + param = sigima.params.LineProfileParam.create(x=-50.0, y=12.0) + dialog = ProfileExtractionDialog("line", param, add_initial_shape=True) + dialog.set_obj(make_image()) + assert (param.x, param.y) == pytest.approx((X0, 12.0)) + assert dialog.shape.get_pos() == pytest.approx((X0, 12.0)) + dialog.reject() + + # Undefined bounds stand for the edges of the image + param = sigima.params.AverageProfileParam.create(x1=-5.0) + dialog = ProfileExtractionDialog("rectangle", param, add_initial_shape=True) + dialog.set_obj(make_image()) + assert dialog.shape.get_rect() == pytest.approx( + (-5.0, Y0 - DY / 2, XMAX + DX / 2, YMAX + DY / 2) + ) + dialog.reject() + + +def test_profile_processor_keeps_last_position() -> None: + """The last profile position is reused, instead of the center of the image""" + with datalab_test_app_context() as win: + panel = win.imagepanel + panel.add_object(make_image()) + proc = panel.processor + # pylint: disable=protected-access + init = proc._ImageProcessor__init_profile_param + saved = proc.PARAM_DEFAULTS.pop("LineProfileParam", None) + try: + edit, param, initial = init(None, sigima.params.LineProfileParam, "P") + assert edit and not initial + obj = panel.objview.get_current_object() + assert (param.x, param.y) == pytest.approx((obj.xc, obj.yc)) + param.x, param.y = 1.0, 15.0 + edit, param, initial = init(None, sigima.params.LineProfileParam, "P") + assert edit and initial + assert (param.x, param.y) == pytest.approx((1.0, 15.0)) + finally: + proc.PARAM_DEFAULTS.pop("LineProfileParam", None) + if saved is not None: + proc.PARAM_DEFAULTS["LineProfileParam"] = saved + + +if __name__ == "__main__": + test_profile_dialog_commits_physical_coordinates() + test_profile_dialog_initial_shape() + test_profile_processor_keeps_last_position() diff --git a/datalab/tests/features/image/profile_dialog_test.py b/datalab/tests/features/image/profile_dialog_test.py index 90e9c61e6..3e7ade346 100644 --- a/datalab/tests/features/image/profile_dialog_test.py +++ b/datalab/tests/features/image/profile_dialog_test.py @@ -23,14 +23,14 @@ def test_profile_unit(): for initial_param in (True, False): if initial_param: if mode == "line": - param = sigima.params.LineProfileParam.create(row=100, col=200) + param = sigima.params.LineProfileParam.create(y=100, x=200) elif mode == "segment": param = sigima.params.SegmentProfileParam.create( - row1=10, col1=20, row2=200, col2=300 + x1=20, y1=10, x2=300, y2=200 ) else: param = sigima.params.AverageProfileParam.create( - row1=10, col1=20, row2=200, col2=300 + x1=20, y1=10, x2=300, y2=200 ) else: if mode == "line": diff --git a/datalab/tests/features/image/profile_recompute_app_test.py b/datalab/tests/features/image/profile_recompute_app_test.py index a8f02d62f..928b4512e 100644 --- a/datalab/tests/features/image/profile_recompute_app_test.py +++ b/datalab/tests/features/image/profile_recompute_app_test.py @@ -46,10 +46,10 @@ def test_profile_recompute_independence(): # First profile (selection A) coords_a = { "direction": "horizontal", - "row1": 10, - "col1": 10, - "row2": 40, - "col2": 60, + "y1": 10, + "x1": 10, + "y2": 40, + "x2": 60, } proc.compute_average_profile( sigima.params.AverageProfileParam.create(**coords_a) @@ -61,10 +61,10 @@ def test_profile_recompute_independence(): # Second profile (selection B, different region) coords_b = { "direction": "vertical", - "row1": 200, - "col1": 150, - "row2": 400, - "col2": 350, + "y1": 200, + "x1": 150, + "y2": 400, + "x2": 350, } proc.compute_average_profile( sigima.params.AverageProfileParam.create(**coords_b) @@ -84,13 +84,13 @@ def test_profile_recompute_independence(): pp_b = extract_processing_parameters(profile_b) assert pp_a is not None and pp_a.param is not None assert pp_b is not None and pp_b.param is not None - assert (pp_a.param.col1, pp_a.param.row1) == ( - coords_a["col1"], - coords_a["row1"], + assert (pp_a.param.x1, pp_a.param.y1) == ( + coords_a["x1"], + coords_a["y1"], ) - assert (pp_b.param.col1, pp_b.param.row1) == ( - coords_b["col1"], - coords_b["row1"], + assert (pp_b.param.x1, pp_b.param.y1) == ( + coords_b["x1"], + coords_b["y1"], ) # Recomputing each profile (through DataLab's recompute engine, using the diff --git a/datalab/tests/features/image/profile_recompute_multiobject_app_test.py b/datalab/tests/features/image/profile_recompute_multiobject_app_test.py index 5d99d537b..45082ea2d 100644 --- a/datalab/tests/features/image/profile_recompute_multiobject_app_test.py +++ b/datalab/tests/features/image/profile_recompute_multiobject_app_test.py @@ -50,10 +50,10 @@ def test_multiobject_recompute_parameter_independence(): # First profile (selection A: horizontal band) coords_a = { "direction": "horizontal", - "row1": 10, - "col1": 10, - "row2": 40, - "col2": 60, + "y1": 10, + "x1": 10, + "y2": 40, + "x2": 60, } proc.compute_average_profile( sigima.params.AverageProfileParam.create(**coords_a) @@ -65,10 +65,10 @@ def test_multiobject_recompute_parameter_independence(): # Second profile (selection B: vertical band, different shape) coords_b = { "direction": "vertical", - "row1": 200, - "col1": 150, - "row2": 400, - "col2": 350, + "y1": 200, + "x1": 150, + "y2": 400, + "x2": 350, } proc.compute_average_profile( sigima.params.AverageProfileParam.create(**coords_b) @@ -105,14 +105,14 @@ def test_multiobject_recompute_parameter_independence(): assert pp_a is not None and pp_a.param is not None assert pp_b is not None and pp_b.param is not None assert pp_a.param.direction == coords_a["direction"] - assert (pp_a.param.col1, pp_a.param.row1) == ( - coords_a["col1"], - coords_a["row1"], + assert (pp_a.param.x1, pp_a.param.y1) == ( + coords_a["x1"], + coords_a["y1"], ) assert pp_b.param.direction == coords_b["direction"] - assert (pp_b.param.col1, pp_b.param.row1) == ( - coords_b["col1"], - coords_b["row1"], + assert (pp_b.param.x1, pp_b.param.y1) == ( + coords_b["x1"], + coords_b["y1"], ) # And the two profiles must remain distinct objects. diff --git a/datalab/tests/features/image/profile_reset_selection_unit_test.py b/datalab/tests/features/image/profile_reset_selection_unit_test.py index b45965c01..b5409bfe4 100644 --- a/datalab/tests/features/image/profile_reset_selection_unit_test.py +++ b/datalab/tests/features/image/profile_reset_selection_unit_test.py @@ -72,13 +72,12 @@ def test_profile_reset_selection_unit(): dialog = ProfileExtractionDialog("rectangle", param, add_initial_shape=False) dialog.set_obj(obj) - # The test image is a 2000x2000 pixel array with unit spacing and - # origin at (0, 0), so physical coordinates map 1:1 to pixel indices: - # each drawn rectangle is committed to `param` with identical values. + # Profile parameters are in physical coordinates: each drawn rectangle is + # committed to `param` with identical values. # First user selection _simulate_draw(dialog, 10, 10, 50, 50) - first_coords = (param.col1, param.row1, param.col2, param.row2) + first_coords = (param.x1, param.y1, param.x2, param.y2) assert first_coords == (10, 10, 50, 50) # User clicks "Reset selection" @@ -97,7 +96,7 @@ def test_profile_reset_selection_unit(): # The caller's `param` object must reflect the *second* selection: the # committed coordinates must match the newly drawn rectangle, not the # first (discarded) one (issue #322). - second_coords = (param.col1, param.row1, param.col2, param.row2) + second_coords = (param.x1, param.y1, param.x2, param.y2) assert second_coords == (100, 100, 150, 150), ( f"'param' holds {second_coords} instead of the second selection " "(100, 100, 150, 150) after Reset selection + new draw (issue #322)" diff --git a/doc/features/image/menu_analysis.rst b/doc/features/image/menu_analysis.rst index 52ba02635..87d7b0b41 100644 --- a/doc/features/image/menu_analysis.rst +++ b/doc/features/image/menu_analysis.rst @@ -66,11 +66,18 @@ Parameters are: Intensity profiles ^^^^^^^^^^^^^^^^^^ +Profiles are defined in the physical coordinates of the image, like regions of +interest and analysis results. The X axis of the resulting signals holds physical +coordinates (line and average profiles) or distances (segment and radial +profiles), with the unit of the corresponding axis of the image. Segment and +radial profiles require the same unit on the X and Y axes of the image. + Line profile ~~~~~~~~~~~~ Extract an horizontal or vertical profile from each selected image, and create -new signals from these profiles. +new signals from these profiles. The profile is extracted along the row (or column) +of pixels whose center is the nearest to the given Y (or X) coordinate. .. figure:: /images/shots/i_profile.png @@ -81,13 +88,17 @@ Segment profile ~~~~~~~~~~~~~~~ Extract a segment profile from each selected image, and create new signals -from these profiles. +from these profiles. The segment is sampled at regular steps, equal to the +smallest distance between neighboring pixel centers, each sample taking the value +of the nearest pixel. Average profile ~~~~~~~~~~~~~~~ Extract an horizontal or vertical profile averaged over a rectangular area, from -each selected image, and create new signals from these profiles. +each selected image, and create new signals from these profiles. The pixels whose +centers lie inside the rectangle (bounds included) are averaged, as for +rectangular regions of interest. .. figure:: /images/shots/i_profile_average.png @@ -111,9 +122,9 @@ The following parameters are available: * - Center - Center around which the radial profile is computed: centroid, image center, or user-defined * - X - - X coordinate of the center (if user-defined), in pixels + - X coordinate of the center (if user-defined), in physical units * - Y - - Y coordinate of the center (if user-defined), in pixels + - Y coordinate of the center (if user-defined), in physical units Horizontal and vertical projections ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/intro/tutorials/fabry_perot.rst b/doc/intro/tutorials/fabry_perot.rst index f0f0f7331..e7b48aabb 100644 --- a/doc/intro/tutorials/fabry_perot.rst +++ b/doc/intro/tutorials/fabry_perot.rst @@ -324,8 +324,8 @@ corresponds to the ``calc("line_profile")`` computation feature of DataLab's API entry |profile| in the "Operations" menu and the "Line profile" dialog will open, letting you select the profile row (for a horizontal profile) or column (for a vertical profile) on the image. -You can also enter the row/column number directly by clicking on "parameters..." in the -"Line profile" dialog. +You can also enter the Y coordinate of the row (or the X coordinate of the column) +directly by clicking on "parameters..." in the "Line profile" dialog. .. figure:: ../../images/tutorials/fabry_perot/24.png @@ -333,8 +333,9 @@ You can also enter the row/column number directly by clicking on "parameters..." .. figure:: ../../images/tutorials/fabry_perot/25.png - The "Profile" dialog opens. Enter the row of the horizontal profile - (or the column of the vertical profile) in the dialog box that opens. Click "OK". + The "Profile" dialog opens. Enter the Y coordinate of the horizontal profile + (or the X coordinate of the vertical profile) in the dialog box that opens. + Click "OK". After clicking "OK", the intensity profile along the X axis is computed and displayed in the "Signals" panel. DataLab automatically switches to the "Signals" diff --git a/doc/intro/tutorials/laser_beam.rst b/doc/intro/tutorials/laser_beam.rst index da6b02875..3aa517c02 100644 --- a/doc/intro/tutorials/laser_beam.rst +++ b/doc/intro/tutorials/laser_beam.rst @@ -163,8 +163,9 @@ To do this, select "Analysis > Centroid" from the menu. The centroid position is displayed on the image. Next, we can extract a line profile along the horizontal axis using -"Analysis > Intensity profiles > Line profile". Set the row position to the -previously computed centroid position (i.e., 668) using the "Set Parameters" button. +"Analysis > Intensity profiles > Line profile". Set the Y coordinate of the +row to the Y coordinate of the previously computed centroid (i.e., 668) using the +"Set Parameters" button. See :ref:`tutorial_fabry_perot` for more details on intensity profile extraction. The intensity profile will be displayed in the "Signal panel". We can then fit the profile diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po index 815ca4364..3376bd4bc 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po @@ -79,11 +79,14 @@ msgstr "Exemple d'histogramme." msgid "Intensity profiles" msgstr "Profils d'intensité" +msgid "Profiles are defined in the physical coordinates of the image, like regions of interest and analysis results. The X axis of the resulting signals holds physical coordinates (line and average profiles) or distances (segment and radial profiles), with the unit of the corresponding axis of the image. Segment and radial profiles require the same unit on the X and Y axes of the image." +msgstr "Les profils sont définis dans les coordonnées physiques de l'image, comme les régions d'intérêt et les résultats d'analyse. L'axe X des signaux obtenus contient des coordonnées physiques (profils rectilignes et moyens) ou des distances (profils de segment et radiaux), avec l'unité de l'axe correspondant de l'image. Les profils de segment et radiaux exigent la même unité sur les axes X et Y de l'image." + msgid "Line profile" msgstr "Profil rectiligne" -msgid "Extract an horizontal or vertical profile from each selected image, and create new signals from these profiles." -msgstr "Extraire un profil horizontal ou vertical de chaque image sélectionnée et créer un nouveau signal à partir de chacun de ces profils." +msgid "Extract an horizontal or vertical profile from each selected image, and create new signals from these profiles. The profile is extracted along the row (or column) of pixels whose center is the nearest to the given Y (or X) coordinate." +msgstr "Extraire un profil horizontal ou vertical de chaque image sélectionnée et créer un nouveau signal à partir de chacun de ces profils. Le profil est extrait le long de la ligne (ou de la colonne) de pixels dont le centre est le plus proche de la coordonnée Y (ou X) indiquée." msgid "Line profile dialog. Parameters may also be set manually (\"Edit profile parameters\" button)." msgstr "Boîte de dialogue d'extraction de profil. Les paramètres peuvent être également définis manuellement (bouton \"Editer les paramètres du profil\")." @@ -91,14 +94,14 @@ msgstr "Boîte de dialogue d'extraction de profil. Les paramètres peuvent être msgid "Segment profile" msgstr "Profil le long d'un segment" -msgid "Extract a segment profile from each selected image, and create new signals from these profiles." -msgstr "Extraire un profil le long d'un segment de chaque image sélectionnée et créer un nouveau signal à partir de chacun de ces profils." +msgid "Extract a segment profile from each selected image, and create new signals from these profiles. The segment is sampled at regular steps, equal to the smallest distance between neighboring pixel centers, each sample taking the value of the nearest pixel." +msgstr "Extraire un profil le long d'un segment de chaque image sélectionnée et créer un nouveau signal à partir de chacun de ces profils. Le segment est échantillonné par pas réguliers, égaux à la plus petite distance entre centres de pixels voisins, chaque échantillon prenant la valeur du pixel le plus proche." msgid "Average profile" msgstr "Profil moyen" -msgid "Extract an horizontal or vertical profile averaged over a rectangular area, from each selected image, and create new signals from these profiles." -msgstr "Extraire un profil horizontal ou vertical moyenné sur une zone rectangulaire de chaque image sélectionnée et créer un nouveau signal à partir de chacun de ces profils." +msgid "Extract an horizontal or vertical profile averaged over a rectangular area, from each selected image, and create new signals from these profiles. The pixels whose centers lie inside the rectangle (bounds included) are averaged, as for rectangular regions of interest." +msgstr "Extraire un profil horizontal ou vertical moyenné sur une zone rectangulaire de chaque image sélectionnée et créer un nouveau signal à partir de chacun de ces profils. Les pixels dont le centre se trouve dans le rectangle (bornes comprises) sont moyennés, comme pour les régions d'intérêt rectangulaires." msgid "Average profile dialog: the area is defined by a rectangle shape. Parameters may also be set manually (\"Edit profile parameters\" button)." msgstr "Boîte de dialogue d'extraction de profil moyen : la zone est définie par un rectangle. Les paramètres peuvent être également définis manuellement (bouton \"Editer les paramètres du profil\")." @@ -121,14 +124,14 @@ msgstr "Centre autour duquel le profil radial est calculé : centre de masse, ce msgid "X" msgstr "X" -msgid "X coordinate of the center (if user-defined), in pixels" -msgstr "Coordonnée X du centre (si défini par l'utilisateur), en pixels" +msgid "X coordinate of the center (if user-defined), in physical units" +msgstr "Coordonnée X du centre (si définie par l'utilisateur), en unités physiques" msgid "Y" msgstr "Y" -msgid "Y coordinate of the center (if user-defined), in pixels" -msgstr "Coordonnée Y du centre (si défini par l'utilisateur), en pixels" +msgid "Y coordinate of the center (if user-defined), in physical units" +msgstr "Coordonnée Y du centre (si définie par l'utilisateur), en unités physiques" msgid "Horizontal and vertical projections" msgstr "Projections horizontale et verticale" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po index 63993be28..21e6e2236 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po @@ -535,42 +535,16 @@ msgstr "" msgid "cross_section" msgstr "cross_section" -msgid "" -"Let's try the first option: select the \"Line profile...\" entry " -"|profile|. This is the most straightforward way to extract a profile from" -" an image, and it corresponds to the ``calc(\"line_profile\")`` " -"computation feature of DataLab's API (so it can be used in a script, a " -"plugin or a macro). Select the \"Line profile...\" entry |profile| in the" -" \"Operations\" menu and the \"Line profile\" dialog will open, letting " -"you select the profile row (for a horizontal profile) or column (for a " -"vertical profile) on the image. You can also enter the row/column number " -"directly by clicking on \"parameters...\" in the \"Line profile\" dialog." -msgstr "" -"Essayons la première option : sélectionnez l'entrée \"Profil " -"rectiligne...\" |profile|. C'est la façon la plus simple d'extraire un " -"profil d'une image, et cela correspond à la fonctionnalité de calcul " -"``calc(\"line_profile\")`` de l'API de DataLab (donc elle peut être " -"utilisée dans un script, un plugin ou une macro). Sélectionnez l'entrée " -"\"Profil rectiligne...\" |profile| dans le menu \"Opérations\" et la " -"boîte de dialogue \"Profil rectiligne\" s'ouvrira, vous permettant de " -"sélectionner la ligne du profil (pour un profil horizontal) ou la colonne" -" (pour un profil vertical) sur l'image. Vous pouvez également entrer " -"directement le numéro de ligne/colonne en cliquant sur \"paramètres...\" " -"dans la boîte de dialogue \"Profil rectiligne\"." +msgid "Let's try the first option: select the \"Line profile...\" entry |profile|. This is the most straightforward way to extract a profile from an image, and it corresponds to the ``calc(\"line_profile\")`` computation feature of DataLab's API (so it can be used in a script, a plugin or a macro). Select the \"Line profile...\" entry |profile| in the \"Operations\" menu and the \"Line profile\" dialog will open, letting you select the profile row (for a horizontal profile) or column (for a vertical profile) on the image. You can also enter the Y coordinate of the row (or the X coordinate of the column) directly by clicking on \"parameters...\" in the \"Line profile\" dialog." +msgstr "Essayons la première option : sélectionnez l'entrée \"Profil rectiligne...\" |profile|. C'est la façon la plus simple d'extraire un profil d'une image, et cela correspond à la fonctionnalité de calcul ``calc(\"line_profile\")`` de l'API de DataLab (donc elle peut être utilisée dans un script, un plugin ou une macro). Sélectionnez l'entrée \"Profil rectiligne...\" |profile| dans le menu \"Opérations\" et la boîte de dialogue \"Profil rectiligne\" s'ouvrira, vous permettant de sélectionner la ligne du profil (pour un profil horizontal) ou la colonne (pour un profil vertical) sur l'image. Vous pouvez également entrer directement la coordonnée Y de la ligne (ou la coordonnée X de la colonne) en cliquant sur \"paramètres...\" dans la boîte de dialogue \"Profil rectiligne\"." msgid "Select the \"Line profile...\" entry |profile| in the \"Operations\" menu." msgstr "" "Sélectionnez l'entrée \"Profil rectiligne...\" |profile| dans le menu " "\"Opérations\"." -msgid "" -"The \"Profile\" dialog opens. Enter the row of the horizontal profile (or" -" the column of the vertical profile) in the dialog box that opens. Click " -"\"OK\"." -msgstr "" -"La boîte de dialogue \"Profil\" s'ouvre. Entrez la ligne du profil " -"horizontal (ou la colonne du profil vertical) dans la boîte de dialogue " -"qui s'ouvre. Cliquez sur \"OK\"." +msgid "The \"Profile\" dialog opens. Enter the Y coordinate of the horizontal profile (or the X coordinate of the vertical profile) in the dialog box that opens. Click \"OK\"." +msgstr "La boîte de dialogue \"Profil\" s'ouvre. Entrez la coordonnée Y du profil horizontal (ou la coordonnée X du profil vertical) dans la boîte de dialogue qui s'ouvre. Cliquez sur \"OK\"." msgid "" "After clicking \"OK\", the intensity profile along the X axis is computed" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po index 3b7c185ee..529cf976f 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po @@ -312,19 +312,8 @@ msgstr "" msgid "The centroid position is displayed on the image." msgstr "La position du centroïde est affichée sur l'image." -msgid "" -"Next, we can extract a line profile along the horizontal axis using " -"\"Analysis > Intensity profiles > Line profile\". Set the row position to" -" the previously computed centroid position (i.e., 668) using the \"Set " -"Parameters\" button. See :ref:`tutorial_fabry_perot` for more details on " -"intensity profile extraction." -msgstr "" -"Ensuite, nous pouvons extraire un profil de ligne le long de l'axe " -"horizontal en utilisant \"Analyse > Profils d'intensité > Profil " -"rectiligne\". Définissez la position de la ligne sur la position du " -"centroïde calculée précédemment (c'est-à-dire 668) en utilisant le bouton" -" \"Définir les paramètres\". Voir :ref:`tutorial_fabry_perot` pour plus " -"de détails sur l'extraction de profil d'intensité." +msgid "Next, we can extract a line profile along the horizontal axis using \"Analysis > Intensity profiles > Line profile\". Set the Y coordinate of the row to the Y coordinate of the previously computed centroid (i.e., 668) using the \"Set Parameters\" button. See :ref:`tutorial_fabry_perot` for more details on intensity profile extraction." +msgstr "Ensuite, nous pouvons extraire un profil de ligne le long de l'axe horizontal en utilisant \"Analyse > Profils d'intensité > Profil rectiligne\". Définissez la coordonnée Y de la ligne sur la coordonnée Y du centroïde calculé précédemment (c'est-à-dire 668) en utilisant le bouton \"Définir les paramètres\". Voir :ref:`tutorial_fabry_perot` pour plus de détails sur l'extraction de profil d'intensité." msgid "" "The intensity profile will be displayed in the \"Signal panel\". We can " diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index 87a062616..cd38b735b 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -539,6 +539,21 @@ msgstr "Les cellules des grilles de ROI (**ROI > Créer une grille de ROI...**) msgid "Regions of interest saved with earlier versions are not converted: they select pixels according to these rules when they are used again, so that processing and analysis in these regions may give slightly different results when recomputed" msgstr "Les régions d'intérêt enregistrées avec des versions antérieures ne sont pas converties : elles sélectionnent les pixels selon ces règles lorsqu'elles sont réutilisées, de sorte que les traitements et analyses dans ces régions peuvent donner des résultats légèrement différents lorsqu'ils sont recalculés" +msgid "**Image profiles in physical coordinates:**" +msgstr "**Profils d'image en coordonnées physiques :**" + +msgid "Line, segment, average and radial profiles (**Analysis > Intensity profiles**) are now defined in the physical coordinates of the image, like regions of interest and analysis results, instead of pixel indices: the profile parameters show coordinates in the units of the image axes, and the X axis of the resulting signals holds physical coordinates or distances with the matching unit. On images without calibration (origin 0, pixel size 1), results are unchanged" +msgstr "Les profils rectilignes, de segment, moyens et radiaux (**Analyse > Profils d'intensité**) sont désormais définis dans les coordonnées physiques de l'image, comme les régions d'intérêt et les résultats d'analyse, au lieu d'indices de pixels : les paramètres des profils indiquent des coordonnées dans les unités des axes de l'image, et l'axe X des signaux obtenus contient des coordonnées ou des distances physiques avec l'unité correspondante. Sur les images sans calibration (origine 0, taille de pixel 1), les résultats sont inchangés" + +msgid "The radial profile is now circular on images with rectangular pixels or non-uniform coordinates, and its center is correct on calibrated images, where the image center was misplaced" +msgstr "Le profil radial est désormais circulaire sur les images à pixels rectangulaires ou à coordonnées non uniformes, et son centre est correct sur les images calibrées, où le centre de l'image était mal placé" + +msgid "Segment and radial profiles are refused on images whose X and Y units differ, since distances are then undefined; coordinates outside the image are refused instead of being silently moved to its edge. Lines and segments drawn beyond the image in the profile dialog are brought back to its outermost pixels" +msgstr "Les profils de segment et radiaux sont refusés sur les images dont les unités X et Y diffèrent, les distances n'étant alors pas définies ; les coordonnées hors de l'image sont refusées au lieu d'être ramenées silencieusement au bord. Les lignes et segments tracés au-delà de l'image dans la boîte de dialogue de profil sont ramenés à ses pixels extrêmes" + +msgid "Scripts, macros and plugins must use the new parameter names (`y`/`x` for line profiles, `x1`, `y1`, `x2`, `y2` for segment and average profiles, `xc`/`yc` for radial profiles) and physical coordinates. Profile computations recorded in the provenance of workspaces saved with earlier versions are reported as not verifiable, instead of being replayed with a different meaning" +msgstr "Les scripts, macros et plugins doivent utiliser les nouveaux noms de paramètres (`y`/`x` pour les profils rectilignes, `x1`, `y1`, `x2`, `y2` pour les profils de segment et moyens, `xc`/`yc` pour les profils radiaux) et des coordonnées physiques. Les calculs de profils enregistrés dans la provenance d'espaces de travail sauvegardés avec des versions antérieures sont signalés comme non vérifiables, au lieu d'être rejoués avec une signification différente" + msgid "Fixed ROIs, annotations and analysis results (centroids, detected peaks, contours...) being shifted by one pixel after a flip or a 90° rotation of an image; after a 90° rotation of a non-square image, or of an image whose coordinates do not start at zero, analysis results even ended up far from the features they describe. They now move exactly with the pixels; for images with non-uniform coordinates, circles, ellipses and polygons are moved approximately when the pixels are reversed along a non-uniform axis (a circle does not stay a circle). ROIs defined in pixel indices are also moved correctly, and inverse ROIs (which mask the inside of their shape) no longer become normal ROIs after these operations, a translation or a distribution of images on a grid" msgstr "Correction du décalage d'un pixel des ROI, des annotations et des résultats d'analyse (centroïdes, pics détectés, contours...) après une symétrie ou une rotation de 90° d'une image ; après une rotation de 90° d'une image non carrée, ou d'une image dont les coordonnées ne commencent pas à zéro, les résultats d'analyse se retrouvaient même loin des éléments qu'ils décrivent. Ils se déplacent désormais exactement avec les pixels ; pour les images à coordonnées non uniformes, les cercles, ellipses et polygones sont déplacés de façon approchée lorsque les pixels sont inversés le long d'un axe non uniforme (un cercle ne reste pas un cercle). Les ROI définies en indices de pixels sont aussi correctement déplacées, et les ROI inverses (qui masquent l'intérieur de leur forme) ne deviennent plus des ROI normales après ces opérations, une translation ou une distribution d'images sur une grille" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index 892c50004..32f5f2b58 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -159,6 +159,13 @@ DataLab now uses stable UUID-based object references across object trees, result * The cells of ROI grids (**ROI > Create ROI grid...**) are now made of whole pixels, so that adjacent cells never share a pixel and a full-size grid covers every pixel exactly once, whatever the image size; on images with non-uniform coordinates, the grid now spans the whole image, whose last rows and columns were left out * Regions of interest saved with earlier versions are not converted: they select pixels according to these rules when they are used again, so that processing and analysis in these regions may give slightly different results when recomputed +**Image profiles in physical coordinates:** + +* Line, segment, average and radial profiles (**Analysis > Intensity profiles**) are now defined in the physical coordinates of the image, like regions of interest and analysis results, instead of pixel indices: the profile parameters show coordinates in the units of the image axes, and the X axis of the resulting signals holds physical coordinates or distances with the matching unit. On images without calibration (origin 0, pixel size 1), results are unchanged +* The radial profile is now circular on images with rectangular pixels or non-uniform coordinates, and its center is correct on calibrated images, where the image center was misplaced +* Segment and radial profiles are refused on images whose X and Y units differ, since distances are then undefined; coordinates outside the image are refused instead of being silently moved to its edge. Lines and segments drawn beyond the image in the profile dialog are brought back to its outermost pixels +* Scripts, macros and plugins must use the new parameter names (`y`/`x` for line profiles, `x1`, `y1`, `x2`, `y2` for segment and average profiles, `xc`/`yc` for radial profiles) and physical coordinates. Profile computations recorded in the provenance of workspaces saved with earlier versions are reported as not verifiable, instead of being replayed with a different meaning + **HDF5 browser:** * Browsing an HDF5 file no longer reads its datasets entirely: they are listed and described from their shape and data type, and the value previews read only their first elements. Large files open faster and use less memory; data is read when a signal or an image is previewed or imported