From 68a22b07f723115e772b44c7996c873834d9ca4e Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 24 Jul 2026 12:39:40 -0700 Subject: [PATCH 1/2] UI option to convert legacy uint64 masks in geff to bool upon import using tracksdata util --- .../import_export/menus/import_dialog.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/motile_tracker/import_export/menus/import_dialog.py b/src/motile_tracker/import_export/menus/import_dialog.py index 7a335812..bc69e42c 100644 --- a/src/motile_tracker/import_export/menus/import_dialog.py +++ b/src/motile_tracker/import_export/menus/import_dialog.py @@ -329,6 +329,53 @@ def _ensure_area_enabled(self) -> None: recompute = "area" not in self.tracks.graph.node_attr_keys() self.tracks.enable_features(["area"], recompute=recompute) + def _maybe_convert_legacy_masks(self, geff_dir: Path) -> bool: + """Offer to convert masks stored in an older, memory-heavy dtype. + + Geff files written by older versions of tracksdata store segmentation + masks as ``uint64`` rather than ``bool``, which uses ~8x more memory + when read and can cause out-of-memory errors. If such masks are + detected, warn the user and offer a lossless, in-place conversion. + + Returns: + bool: True to continue the import, False to abort. + """ + try: + from tracksdata.io import convert_geff_masks_to_bool, geff_mask_dtype + except ImportError: + return True # older tracksdata without the utility; import as before + + try: + dtype = geff_mask_dtype(geff_dir) + except Exception: # noqa: BLE001 + return True # detection failed; don't block the import + + if dtype is None or dtype == "bool": + return True + + answer = QMessageBox.question( + self, + "Old mask format detected", + f"This geff stores segmentation masks as '{dtype}' (an older format).\n\n" + "Loading them as-is uses about 8x more memory and may run out of memory " + "on large datasets. Convert the masks to boolean now? This edits the file " + "in place and is lossless.", + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, + QMessageBox.Yes, + ) + if answer == QMessageBox.Cancel: + return False + if answer == QMessageBox.Yes: + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + convert_geff_masks_to_bool(geff_dir) + except Exception as e: # noqa: BLE001 + QMessageBox.critical(self, "Error", f"Failed to convert masks: {e}") + return False + finally: + QApplication.restoreOverrideCursor() + return True + def _finish(self) -> None: """Tries to read the csv/geff file and optional segmentation image and apply the attribute to column mapping to construct a Tracks object""" @@ -339,6 +386,9 @@ def _finish(self) -> None: group_path = Path(self.import_widget.root.path) # e.g. 'tracks' geff_dir = store_path / group_path + if not self._maybe_convert_legacy_masks(geff_dir): + return + self.name = self.import_widget.dir_name scale = self.scale_widget.get_scale() if self.seg else None From c4fb6eac7b424aaf18d981ae303ab495ac6a3bbd Mon Sep 17 00:00:00 2001 From: Teun Huijben Date: Fri, 24 Jul 2026 14:10:17 -0700 Subject: [PATCH 2/2] do boolean conversion per specified mask key --- .../import_export/menus/import_dialog.py | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/motile_tracker/import_export/menus/import_dialog.py b/src/motile_tracker/import_export/menus/import_dialog.py index bc69e42c..14b05602 100644 --- a/src/motile_tracker/import_export/menus/import_dialog.py +++ b/src/motile_tracker/import_export/menus/import_dialog.py @@ -329,52 +329,64 @@ def _ensure_area_enabled(self) -> None: recompute = "area" not in self.tracks.graph.node_attr_keys() self.tracks.enable_features(["area"], recompute=recompute) - def _maybe_convert_legacy_masks(self, geff_dir: Path) -> bool: + def _maybe_convert_legacy_masks(self, geff_dir: Path) -> Path | None: """Offer to convert masks stored in an older, memory-heavy dtype. Geff files written by older versions of tracksdata store segmentation - masks as ``uint64`` rather than ``bool``, which uses ~8x more memory - when read and can cause out-of-memory errors. If such masks are - detected, warn the user and offer a lossless, in-place conversion. + masks as integers (e.g. ``uint64``) rather than ``bool``, using ~8x more + memory when read. If such masks are detected, warn the user and offer a + lossless conversion. To keep the original (possibly shared) file + untouched, the conversion is written to a ``*_bool.geff`` copy next to + it, and that copy is loaded instead. Returns: - bool: True to continue the import, False to abort. + Path | None: the geff directory to import (the original, or the + converted copy), or None if the user cancelled. """ try: - from tracksdata.io import convert_geff_masks_to_bool, geff_mask_dtype + from tracksdata.io import convert_geff_mask_to_bool, geff_mask_dtype except ImportError: - return True # older tracksdata without the utility; import as before + return geff_dir # older tracksdata without the utility; import as before try: dtype = geff_mask_dtype(geff_dir) except Exception: # noqa: BLE001 - return True # detection failed; don't block the import + return geff_dir # detection failed; don't block the import if dtype is None or dtype == "bool": - return True + return geff_dir answer = QMessageBox.question( self, "Old mask format detected", - f"This geff stores segmentation masks as '{dtype}' (an older format).\n\n" - "Loading them as-is uses about 8x more memory and may run out of memory " - "on large datasets. Convert the masks to boolean now? This edits the file " - "in place and is lossless.", + f"This geff stores segmentation masks as '{dtype}', an older format that " + "uses about 8x more memory when loaded.\n\n" + "Convert them to boolean now? A converted copy will be written next to the " + "original, which is left untouched.", QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes, ) if answer == QMessageBox.Cancel: - return False - if answer == QMessageBox.Yes: - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - convert_geff_masks_to_bool(geff_dir) - except Exception as e: # noqa: BLE001 - QMessageBox.critical(self, "Error", f"Failed to convert masks: {e}") - return False - finally: - QApplication.restoreOverrideCursor() - return True + return None + if answer == QMessageBox.No: + return geff_dir # load the original as-is + + name = geff_dir.name + out_name = ( + f"{name[:-5]}_bool.geff" if name.endswith(".geff") else f"{name}_bool" + ) + out_path = geff_dir.parent / out_name + + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + if not out_path.exists(): + convert_geff_mask_to_bool(geff_dir, output_path=out_path) + except Exception as e: # noqa: BLE001 + QMessageBox.critical(self, "Error", f"Failed to convert masks: {e}") + return None + finally: + QApplication.restoreOverrideCursor() + return out_path def _finish(self) -> None: """Tries to read the csv/geff file and optional segmentation image and apply the @@ -386,7 +398,8 @@ def _finish(self) -> None: group_path = Path(self.import_widget.root.path) # e.g. 'tracks' geff_dir = store_path / group_path - if not self._maybe_convert_legacy_masks(geff_dir): + geff_dir = self._maybe_convert_legacy_masks(geff_dir) + if geff_dir is None: return self.name = self.import_widget.dir_name