diff --git a/datalab/adapters_metadata/common.py b/datalab/adapters_metadata/common.py
index 327f7c19..d63052fc 100644
--- a/datalab/adapters_metadata/common.py
+++ b/datalab/adapters_metadata/common.py
@@ -109,7 +109,7 @@ def append(self, adapter: BaseResultAdapter, obj: SignalObj | ImageObj) -> None:
if "roi_index" in df.columns:
i_roi = int(df.iloc[i_row_res]["roi_index"])
roititle = ""
- if i_roi >= 0 and obj.roi is not None:
+ if i_roi >= 0 and obj.roi is not None and i_roi < len(obj.roi):
roititle = obj.roi.get_single_roi_title(i_roi)
ylabel += f"|{roititle}"
self.ylabels.append(ylabel)
diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py
index 0d4a659c..fd421fed 100644
--- a/datalab/gui/processor/base.py
+++ b/datalab/gui/processor/base.py
@@ -792,6 +792,30 @@ def register_computations(self) -> None:
self.register_processing()
self.register_analysis()
+ # pylint: disable=unused-argument
+ def preprocess_1_to_0(
+ self,
+ func: Callable,
+ param: gds.DataSet | None,
+ objs: list[SignalObj | ImageObj],
+ ) -> bool:
+ """Pre-check hook for 1-to-0 operations (hook method).
+
+ This method is called before a 1-to-0 computation starts, before the
+ progress dialog is opened. Subclasses can override this method to perform
+ pre-checks or ask for user confirmation. Return ``False`` to abort the
+ computation.
+
+ Args:
+ func: The computation function that will be called
+ param: Optional parameter set
+ objs: List of objects that will be processed
+
+ Returns:
+ True to proceed with the computation, False to abort
+ """
+ return True
+
# pylint: disable=unused-argument
def postprocess_1_to_0_result(
self, obj: SignalObj | ImageObj, result: GeometryResult | TableResult
@@ -989,6 +1013,13 @@ def auto_recompute_analysis(
# Get the parameter from processing parameters
param = proc_params.param
+ # Disable ROI creation during auto-recompute: detection functions store
+ # create_rois=True in their parameters, but auto-recompute should only
+ # update analysis results, not recreate ROIs (which would make them
+ # impossible to delete or modify).
+ if hasattr(param, "create_rois"):
+ param.create_rois = False
+
# Get the actual function from the function name
feature = self.get_feature(proc_params.func_name)
@@ -1394,6 +1425,8 @@ def compute_1_to_0(
if target_objs is not None
else self.panel.objview.get_sel_objects(include_groups=True)
)
+ if not self.preprocess_1_to_0(func, param, objs):
+ return None
current_obj = self.panel.objview.get_current_object()
title = func.__name__ if title is None else title
refresh_needed = False
diff --git a/datalab/gui/processor/image.py b/datalab/gui/processor/image.py
index ece5824f..2f38feba 100644
--- a/datalab/gui/processor/image.py
+++ b/datalab/gui/processor/image.py
@@ -8,6 +8,7 @@
from __future__ import annotations
+import guidata.dataset as gds
import numpy as np
import sigima.params
import sigima.proc.base as sipb
@@ -25,6 +26,7 @@
)
from sigima.objects.scalar import GeometryResult, TableResult
+from datalab import env
from datalab.config import APP_NAME, _
from datalab.gui.processor.base import BaseProcessor
from datalab.gui.processor.geometry_postprocess import (
@@ -58,6 +60,50 @@ def _wrap_geometric_transform(self, func, operation: str):
"""
return GeometricTransformWrapper(func, operation)
+ def preprocess_1_to_0(
+ self,
+ func,
+ param: gds.DataSet | None,
+ objs: list[ImageObj],
+ ) -> bool:
+ """Override to confirm ROI replacement before the progress bar opens.
+
+ When the parameter has ``create_rois=True`` and at least one selected
+ image already has ROIs, the user is warned that the existing ROIs will
+ be replaced.
+
+ Args:
+ func: The computation function that will be called
+ param: Optional parameter set
+ objs: List of image objects that will be processed
+
+ Returns:
+ True to proceed with the computation, False to abort
+ """
+ if (
+ param is not None
+ and getattr(param, "create_rois", False)
+ and not env.execenv.unattended
+ and any(obj.roi is not None and not obj.roi.is_empty() for obj in objs)
+ ):
+ return (
+ QW.QMessageBox.question(
+ self.mainwindow,
+ _("Warning"),
+ _(
+ "Regions of interest are already defined for this "
+ "image.
"
+ "Creating new ROIs from detection will replace the "
+ "existing ones, which will be lost.
"
+ "Do you want to continue?"
+ ),
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ == QW.QMessageBox.Yes
+ )
+ return True
+
def postprocess_1_to_0_result(
self, obj: ImageObj, result: GeometryResult | TableResult
) -> bool:
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 371ffaae..e2cac862 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -953,38 +953,6 @@ msgstr "Troncature de données uint32 en int32."
msgid "No supported data available in HDF5 file(s)."
msgstr "Aucune donnée prise en charge dans le(s) fichier(s) HDF5."
-msgid "Generate macro"
-msgstr "Générer une macro"
-
-msgid "No compute actions to export."
-msgstr "Pas d'actions de calcul à exporter."
-
-#, python-format
-msgid "Macro script copied to clipboard (%d actions)."
-msgstr "Script de macro copié dans le presse-papier (%d actions)."
-
-msgid ""
-"Do you really want to delete the selected items?\n"
-"\n"
-"Note: deleting an action also removes all subsequent actions in the same session."
-msgstr ""
-"Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?\n"
-"\n"
-"Remarque : la suppression d'une action entraîne également la suppression de toutes les actions suivantes dans la même session."
-
-msgid "Do you really want to delete the selected items?"
-msgstr "Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?"
-
-msgid "Remove incompatible"
-msgstr "Supprimer les actions incompatibles"
-
-msgid "All actions are compatible with the current workspace."
-msgstr "Toutes les actions sont compatibles avec l'espace de travail actuel."
-
-#, python-format
-msgid "%d incompatible action(s) will be removed. Continue?"
-msgstr "%d action(s) incompatible(s) seront supprimées. Continuer ?"
-
msgid "Macro simple example"
msgstr "Exemple simple de macro"
@@ -1523,12 +1491,12 @@ msgstr "Supprimer les métadonnées"
msgid "Some selected objects have regions of interest.
Do you want to delete them as well?"
msgstr "Certains objets sélectionnés ont des régions d'intérêt.
Souhaitez-vous les supprimer également ?"
-msgid "Group name:"
-msgstr "Nom du groupe :"
-
msgid "New group"
msgstr "Nouveau groupe"
+msgid "Group name:"
+msgstr "Nom du groupe :"
+
msgid "Rename object"
msgstr "Renommer l'objet"
@@ -2035,6 +2003,9 @@ msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins de
msgid "In pairwise mode, you need to select the same number of objects in each group."
msgstr "En mode 'pairwise', vous devez sélectionner le même nombre d'objets dans chaque groupe."
+msgid "Parameters"
+msgstr "Paramètres"
+
#, python-format
msgid "Calculating: %s"
msgstr "Calcul : %s"
@@ -2066,6 +2037,9 @@ msgstr "Supprimer la ROI"
msgid "Are you sure you want to remove ROI '%s'?"
msgstr "Êtes-vous sûr de vouloir supprimer la ROI '%s' ?"
+msgid "Regions of interest are already defined for this image.
Creating new ROIs from detection will replace the existing ones, which will be lost.
Do you want to continue?"
+msgstr "Des régions d'intérêt sont déjà définies pour cette image.
La création de nouvelles ROI par détection remplacera les existantes, qui seront perdues.
Voulez-vous continuer ?"
+
msgid "Sum"
msgstr "Addition"
@@ -3335,32 +3309,6 @@ msgstr "C'est la fin de la visite guidée !"
msgid "You can show the tour again, or close this dialog box."
msgstr "Vous pouvez afficher la visite guidée à nouveau, ou fermer cette boîte de dialogue."
-msgid "Save history file"
-msgstr "Enregistrer le fichier d'historique"
-
-msgid "Open history file"
-msgstr "Ouvrir le fichier d'historique"
-
-msgid "Imported"
-msgstr "Importer"
-
-msgid "Replaying compound 'multiple_1_to_1' actions is not supported yet."
-msgstr "La relecture des actions composées 'multiple_1_to_1' n'est pas encore prise en charge."
-
-msgid "Cannot replay 2-to-1 action: source object(s) missing."
-msgstr "Impossible de relire l'action 2-à-1 : objet(s) source manquant(s)."
-
-#, python-format
-msgid "Failed to deserialize history DataSet kwarg %r."
-msgstr "Echec de la désérialisation de l'argument DataSet de l'historique %r."
-
-#, python-format
-msgid "Failed to deserialize history DataSet-list kwarg %r."
-msgstr "Echec de la désérialisation de l'argument DataSet-list de l'historique %r."
-
-msgid "Session"
-msgstr "Session"
-
msgid "Registered plugins:"
msgstr "Plugins enregistrés :"
@@ -3427,9 +3375,6 @@ msgstr "Créer une image avec un anneau"
msgid "Create image with a grid of gaussian spots"
msgstr "Créer une image avec une grille de spots gaussiens"
-msgid "New signal"
-msgstr "Nouveau signal"
-
msgid "Host application"
msgstr "Application hôte"
@@ -3505,12 +3450,12 @@ msgstr "Cliquer sur OK pour démarrer la démo.
Note :
- La dé
msgid "Click OK to end demo."
msgstr "Cliquer sur OK pour terminer la démo."
-msgid "Context"
-msgstr "Contexte"
-
msgid "Error:"
msgstr "Erreur:"
+msgid "Context"
+msgstr "Contexte"
+
#, python-format
msgid "The file %s could not be read:"
msgstr "Le fichier %s n'a pas pu être ouvert :"
@@ -3727,18 +3672,18 @@ msgstr "Déplier la sélection"
msgid "HDF5 Browser"
msgstr "Explorateur HDF5"
-msgid "Value"
-msgstr "Valeur"
-
-msgid "Name"
-msgstr "Nom"
-
msgid "Size"
msgstr "Taille"
msgid "Type"
msgstr "Type"
+msgid "Value"
+msgstr "Valeur"
+
+msgid "Name"
+msgstr "Nom"
+
msgid "Unsupported data"
msgstr "Données non prises en charge"
@@ -3775,24 +3720,6 @@ msgstr "Afficher uniquement les données prises en charge"
msgid "Show values"
msgstr "Afficher les valeurs"
-msgid "Show details"
-msgstr "Afficher les détails"
-
-msgid "Hide details"
-msgstr "Masquer les détails"
-
-msgid "Date and time"
-msgstr "Date et heure"
-
-msgid "Title"
-msgstr "Titre"
-
-msgid "Action is compatible with the current workspace state."
-msgstr "L'action est compatible avec l'état actuel de l'espace de travail."
-
-msgid "Action is not compatible with the current workspace state."
-msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail."
-
msgid "Image background selection"
msgstr "Sélection de l'arrière-plan de l'image"
@@ -4074,6 +4001,9 @@ msgstr "Tout sélectionner"
msgid "Adding data to the plot"
msgstr "Ajout des données au graphique"
+msgid "Title"
+msgstr "Titre"
+
msgid "X label"
msgstr "Titre X"
@@ -4227,3 +4157,4 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
+
diff --git a/datalab/tests/features/image/detection_roi_replace_unit_test.py b/datalab/tests/features/image/detection_roi_replace_unit_test.py
new file mode 100644
index 00000000..a78832d4
--- /dev/null
+++ b/datalab/tests/features/image/detection_roi_replace_unit_test.py
@@ -0,0 +1,326 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Detection ROI replacement confirmation test
+
+Testing the following:
+ - When create_rois=True and image already has ROIs, the preprocess hook
+ runs before the progress bar and can abort the computation
+ - In unattended mode (automated tests) the dialog is skipped and ROIs
+ are always replaced
+ - When create_rois=False, existing ROIs are left untouched
+ - When no existing ROIs are present, ROI creation proceeds normally
+ - When the user cancels the confirmation dialog, existing ROIs are preserved
+ - The confirmation dialog is shown only when ROIs already exist
+ - contour_shape with create_rois=True creates ROIs in DataLab integration
+ - ROI modification loop is broken (no infinite re-creation cycle)
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import sigima.params
+import sigima.proc.image as sipi
+from qtpy import QtWidgets as QW
+from sigima.enums import ContourShape
+from sigima.objects import NewImageParam, create_image_roi
+from sigima.tests.data import create_multigaussian_image, create_peak_image
+
+from datalab.env import execenv
+from datalab.tests import datalab_test_app_context
+from datalab.tests.features.image.roi_app_test import IROI1, IROI2
+
+
+def _create_image_with_roi():
+ """Return a multigaussian image that already has ROIs defined."""
+ newparam = NewImageParam.create(height=200, width=200)
+ ima = create_multigaussian_image(newparam)
+ roi = create_image_roi("rectangle", IROI1)
+ roi.add_roi(create_image_roi("circle", IROI2))
+ ima.roi = roi
+ return ima
+
+
+def test_create_rois_no_existing_roi():
+ """ROIs are created normally when the image has no pre-existing ROI."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = create_peak_image()
+ assert ima.roi is None
+ panel.add_object(ima)
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ result = panel.processor.compute_peak_detection(param)
+ assert result is not None, "Peak detection should return results"
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "ROI should be created when create_rois=True"
+ assert not obj.roi.is_empty(), "Created ROI should not be empty"
+
+
+def test_create_rois_with_existing_roi_unattended():
+ """In unattended mode, existing ROIs are silently replaced (no dialog)."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = _create_image_with_roi()
+ initial_roi = ima.roi
+ panel.add_object(ima)
+
+ # execenv.unattended is True in the test suite: the dialog is skipped
+ assert execenv.unattended, "This test requires unattended mode"
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ result = panel.processor.compute_peak_detection(param)
+ assert result is not None, "Peak detection should return results"
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "ROI should be present after detection"
+ assert obj.roi != initial_roi, (
+ "Existing ROI should have been replaced in unattended mode"
+ )
+
+
+def test_create_rois_false_preserves_existing_roi():
+ """When create_rois=False, existing ROIs are never touched."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = _create_image_with_roi()
+ panel.add_object(ima)
+
+ obj = panel.objview.get_current_object()
+ roi_before = obj.roi
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=False)
+ panel.processor.compute_peak_detection(param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi == roi_before, (
+ "Existing ROI must not be modified when create_rois=False"
+ )
+
+
+def test_dialog_shown_only_when_roi_exists():
+ """The confirmation dialog is triggered only when the image already has ROIs.
+
+ - With existing ROIs and create_rois=True: preprocess_1_to_0 calls the dialog
+ - Without existing ROIs and create_rois=True: preprocess_1_to_0 returns True
+ directly, without opening any dialog
+ """
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+
+ # Case 1: image with no ROI — dialog must NOT be shown
+ ima_no_roi = create_peak_image()
+ panel.add_object(ima_no_roi)
+ objs_no_roi = panel.objview.get_sel_objects(include_groups=True)
+
+ with patch.object(QW.QMessageBox, "question") as mock_question:
+ execenv.unattended = False
+ try:
+ result = panel.processor.preprocess_1_to_0(
+ sipi.peak_detection, param, objs_no_roi
+ )
+ finally:
+ execenv.unattended = True
+
+ assert result is True, "Should proceed when no existing ROIs"
+ mock_question.assert_not_called()
+
+ # Case 2: image with existing ROI — dialog MUST be shown
+ ima_with_roi = _create_image_with_roi()
+ panel.add_object(ima_with_roi)
+ objs_with_roi = panel.objview.get_sel_objects(include_groups=True)
+
+ with patch.object(
+ QW.QMessageBox, "question", return_value=QW.QMessageBox.Yes
+ ) as mock_question:
+ execenv.unattended = False
+ try:
+ result = panel.processor.preprocess_1_to_0(
+ sipi.peak_detection, param, objs_with_roi
+ )
+ finally:
+ execenv.unattended = True
+
+ assert result is True, "Should proceed when user confirms"
+ mock_question.assert_called_once()
+
+
+def test_cancel_dialog_preserves_existing_roi():
+ """When the user cancels the confirmation dialog, existing ROIs are preserved."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = _create_image_with_roi()
+ panel.add_object(ima)
+
+ obj = panel.objview.get_current_object()
+ roi_before = obj.roi
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+
+ # Simulate the user clicking "No" in the confirmation dialog
+ with patch.object(QW.QMessageBox, "question", return_value=QW.QMessageBox.No):
+ execenv.unattended = False
+ try:
+ result = panel.processor.compute_peak_detection(param)
+ finally:
+ execenv.unattended = True
+
+ assert result is None, "Computation should be aborted when user cancels"
+ obj = panel.objview.get_current_object()
+ assert obj.roi == roi_before, (
+ "Existing ROI must be preserved when user cancels the dialog"
+ )
+
+
+def test_preprocess_hook_abort_skipped_in_unattended():
+ """preprocess_1_to_0 returns True in unattended mode (no blocking dialog)."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = _create_image_with_roi()
+ panel.add_object(ima)
+
+ assert execenv.unattended, "This test requires unattended mode"
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ objs = panel.objview.get_sel_objects(include_groups=True)
+
+ # In unattended mode the hook must always return True (no dialog shown)
+ result = panel.processor.preprocess_1_to_0(sipi.peak_detection, param, objs)
+ assert result is True, "preprocess_1_to_0 must return True in unattended mode"
+
+
+def test_auto_recompute_does_not_replace_rois():
+ """auto_recompute_analysis must not recreate ROIs deleted by the user.
+
+ Scenario:
+ 1. Run peak detection with create_rois=True → ROIs are created and the
+ analysis parameters (including create_rois=True) are stored in the
+ object's metadata.
+ 2. The user deletes the ROIs manually.
+ 3. auto_recompute_analysis is triggered (e.g. after a data change).
+ 4. The ROIs must NOT be recreated: auto_recompute_analysis disables
+ create_rois before calling compute_1_to_0.
+ """
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = create_peak_image()
+ panel.add_object(ima)
+
+ # Step 1: run detection with ROI creation to store analysis params
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ panel.processor.compute_peak_detection(param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "Peak detection should have created ROIs"
+
+ # Step 2: user deletes the ROIs
+ obj.roi = None
+
+ # Step 3 & 4: auto-recompute must NOT recreate the ROIs
+ panel.processor.auto_recompute_analysis(obj)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is None, (
+ "auto_recompute_analysis must not recreate ROIs "
+ "(create_rois is disabled during auto-recompute)"
+ )
+
+
+def test_contour_shape_creates_rois_in_datalab():
+ """Integration test: contour_shape with create_rois=True creates ROIs via DataLab.
+
+ This verifies the full DataLab integration path for the Sigima
+ feature/20-contour-to-roi feature: the contour_shape computation function
+ is called through run_feature, and the postprocess hook
+ (apply_detection_rois) creates the ROIs on the image object.
+ """
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ newparam = NewImageParam.create(height=200, width=200)
+ ima = create_multigaussian_image(newparam)
+ panel.add_object(ima)
+
+ for shape in ContourShape:
+ # Reset: remove ROIs from previous iteration
+ obj = panel.objview.get_current_object()
+ obj.roi = None
+
+ param = sigima.params.ContourShapeParam.create(
+ shape=shape, create_rois=True
+ )
+ panel.processor.run_feature("contour_shape", param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, (
+ f"contour_shape({shape.name}) with create_rois=True "
+ "must create ROIs in DataLab"
+ )
+ assert not obj.roi.is_empty(), (
+ f"contour_shape({shape.name}) ROI must not be empty"
+ )
+
+
+def test_no_infinite_roi_recreation_loop():
+ """The ROI creation → auto-recompute cycle must not loop infinitely.
+
+ Full scenario matching the real user workflow:
+ 1. Run detection with create_rois=True → ROIs are created.
+ 2. Simulate what happens when the user edits ROIs: auto_recompute_analysis
+ is called (as DataLab does after ROI graphical editing).
+ 3. Verify that auto_recompute_analysis does NOT recreate ROIs.
+ 4. Repeat step 2 a second time to confirm stability.
+
+ This test guards against the semi-infinite loop described in issue #329:
+ modifying ROIs triggers auto-recompute, which re-runs the detection
+ function. If create_rois stays True in the recompute path, the detection
+ would overwrite the user's ROI edit, triggering another recompute, etc.
+ """
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = create_peak_image()
+ panel.add_object(ima)
+
+ # Step 1: detection with ROI creation
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ panel.processor.compute_peak_detection(param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "Initial detection should create ROIs"
+
+ # Step 2: simulate user editing ROIs (replace with a single rectangle)
+ obj.roi = create_image_roi("rectangle", [10, 10, 50, 50])
+ user_roi = obj.roi
+
+ # Step 3: auto-recompute fires (as DataLab does after ROI edit)
+ panel.processor.auto_recompute_analysis(obj)
+
+ obj = panel.objview.get_current_object()
+ # The ROI must be the user's edited ROI, NOT a new set from detection
+ assert obj.roi is user_roi, (
+ "auto_recompute must not replace the user's manually edited ROI"
+ )
+
+ # Step 4: a second auto-recompute must also be stable
+ panel.processor.auto_recompute_analysis(obj)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is user_roi, (
+ "Second auto_recompute must still preserve the user's ROI (no oscillation)"
+ )
+
+
+if __name__ == "__main__":
+ test_create_rois_no_existing_roi()
+ test_create_rois_with_existing_roi_unattended()
+ test_create_rois_false_preserves_existing_roi()
+ test_dialog_shown_only_when_roi_exists()
+ test_cancel_dialog_preserves_existing_roi()
+ test_preprocess_hook_abort_skipped_in_unattended()
+ test_auto_recompute_does_not_replace_rois()
+ test_contour_shape_creates_rois_in_datalab()
+ test_no_infinite_roi_recreation_loop()